/*
 * Strawberry Music Player
 * This file was part of Clementine.
 * Copyright 2010, David Sansome <me@davidsansome.com>
 * Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
 *
 * Strawberry is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Strawberry is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Strawberry.  If not, see <http://www.gnu.org/licenses/>.
 *
 */

#include "config.h"

#include <utility>
#include <chrono>

#include <QObject>
#include <QThread>
#include <QIODevice>
#include <QStorageInfo>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QMetaObject>
#include <QDateTime>
#include <QHash>
#include <QMap>
#include <QList>
#include <QSet>
#include <QTimer>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QImage>
#include <QMutexLocker>
#include <QSettings>

#include "core/logging.h"
#include "core/taskmanager.h"
#include "core/settings.h"
#include "utilities/imageutils.h"
#include "constants/timeconstants.h"
#include "constants/filesystemconstants.h"
#include "tagreader/tagreaderclient.h"
#include "collectiondirectory.h"
#include "collectionbackend.h"
#include "collectionwatcher.h"
#include "playlistparsers/cueparser.h"
#include "constants/collectionsettings.h"
#include "engine/ebur128measures.h"

#if defined(Q_OS_LINUX)
#  include "core/filesystemwatcherinotify.h"
#elif defined(Q_OS_WIN32)
#  include "core/filesystemwatcherwin.h"
#else
#  include "core/filesystemwatcherqt.h"
#endif

#ifdef HAVE_SONGFINGERPRINTING
#  include "engine/chromaprinter.h"
#endif
#ifdef HAVE_EBUR128
#  include "engine/ebur128analysis.h"
#endif

// This is defined by one of the windows headers that is included by taglib.
#ifdef RemoveDirectory
#  undef RemoveDirectory
#endif

using namespace std::chrono_literals;
using namespace Qt::Literals::StringLiterals;

QStringList CollectionWatcher::sValidImages = QStringList() << u"jpg"_s << u"jpeg"_s << u"jp2"_s << u"png"_s << u"gif"_s << u"tiff"_s << u"tif"_s << u"webp"_s;

CollectionWatcher::CollectionWatcher(const Song::Source source,
                                     const SharedPtr<TaskManager> task_manager,
                                     const SharedPtr<TagReaderClient> tagreader_client,
                                     const SharedPtr<CollectionBackend> backend,
                                     QObject *parent)
    : QObject(parent),
      source_(source),
      task_manager_(task_manager),
      tagreader_client_(tagreader_client),
      backend_(backend),
#if defined(Q_OS_LINUX)
      fs_watcher_(new FileSystemWatcherInotify(this)),
#elif defined(Q_OS_WIN32)
      fs_watcher_(new FileSystemWatcherWin(this)),
#else
      fs_watcher_(new FileSystemWatcherQt(this)),
#endif
      original_thread_(nullptr),
      scan_on_startup_(true),
      monitor_(true),
      song_tracking_(false),
      song_ebur128_loudness_analysis_(false),
      mark_songs_unavailable_(source_ == Song::Source::Collection),
      expire_unavailable_songs_days_(60),
      overwrite_playcount_(false),
      overwrite_rating_(false),
      stop_requested_(false),
      abort_requested_(false),
      rescan_timer_(new QTimer(this)),
      periodic_scan_timer_(new QTimer(this)),
      rescan_paused_(false),
      total_watches_(0),
      cue_parser_(new CueParser(tagreader_client, backend, this)),
      last_scan_time_(0) {

  setObjectName(source_ == Song::Source::Collection ? QLatin1String(QObject::metaObject()->className()) : QStringLiteral("%1%2").arg(Song::DescriptionForSource(source_), QLatin1String(QObject::metaObject()->className())));

  original_thread_ = thread();

  rescan_timer_->setInterval(2s);
  rescan_timer_->setSingleShot(true);

  periodic_scan_timer_->setInterval(86400 * kMsecPerSec);
  periodic_scan_timer_->setSingleShot(false);

  const QStringList image_formats = ImageUtils::SupportedImageFormats();
  for (const QString &format : image_formats) {
    if (!sValidImages.contains(format)) {
      sValidImages.append(format);
    }
  }

  ReloadSettings();

  QObject::connect(fs_watcher_, &FileSystemWatcherInterface::PathChanged, this, &CollectionWatcher::DirectoryChanged, Qt::UniqueConnection);
  QObject::connect(rescan_timer_, &QTimer::timeout, this, &CollectionWatcher::RescanPathsNow);
  QObject::connect(periodic_scan_timer_, &QTimer::timeout, this, &CollectionWatcher::IncrementalScanCheck);

}

CollectionWatcher::~CollectionWatcher() {

  qLog(Debug) << "Collection watcher" << this << "for" << Song::TextForSource(source_) << "deleted.";

}

void CollectionWatcher::ExitAsync() {
  QMetaObject::invokeMethod(this, &CollectionWatcher::Exit, Qt::QueuedConnection);
}

void CollectionWatcher::Exit() {

  Q_ASSERT(QThread::currentThread() == thread());

  Abort();
  if (backend_) backend_->Close();
  moveToThread(original_thread_);
  Q_EMIT ExitFinished();

}

void CollectionWatcher::Stop() {

  QMutexLocker l(&mutex_stop_);
  stop_requested_ = true;

}

void CollectionWatcher::CancelStop() {

  QMutexLocker l(&mutex_stop_);
  stop_requested_ = false;

}

bool CollectionWatcher::stop_requested() const {

  QMutexLocker l(&mutex_stop_);
  return stop_requested_;

}

void CollectionWatcher::Abort() {

  QMutexLocker l(&mutex_abort_);
  abort_requested_ = true;

}

bool CollectionWatcher::abort_requested() const {

  QMutexLocker l(&mutex_abort_);
  return abort_requested_;

}

bool CollectionWatcher::stop_or_abort_requested() const {

  return stop_requested() || abort_requested();

}

void CollectionWatcher::ReloadSettingsAsync() {

  QMetaObject::invokeMethod(this, &CollectionWatcher::ReloadSettings, Qt::QueuedConnection);

}

void CollectionWatcher::ReloadSettings() {

  const bool was_monitoring_before = monitor_;
  Settings s;
  s.beginGroup(CollectionSettings::kSettingsGroup);
  if (source_ == Song::Source::Collection) {
    scan_on_startup_ = s.value(CollectionSettings::kStartupScan, true).toBool();
    monitor_ = s.value(CollectionSettings::kMonitor, true).toBool();
  }
  else {
    scan_on_startup_ = true;
    monitor_ = true;
  }
  const QStringList filters = s.value(CollectionSettings::kCoverArtPatterns, QStringList() << u"front"_s << u"cover"_s).toStringList();
  if (source_ == Song::Source::Collection) {
    song_tracking_ = s.value(CollectionSettings::kSongTracking, false).toBool();
    song_ebur128_loudness_analysis_ = s.value(CollectionSettings::kSongENUR128LoudnessAnalysis, false).toBool();
    mark_songs_unavailable_ = song_tracking_ ? true : s.value(CollectionSettings::kMarkSongsUnavailable, true).toBool();
  }
  else {
    song_tracking_ = false;
    song_ebur128_loudness_analysis_ = false;
    mark_songs_unavailable_ = false;
  }
  expire_unavailable_songs_days_ = s.value(CollectionSettings::kExpireUnavailableSongs, 60).toInt();
  overwrite_playcount_ = s.value(CollectionSettings::kOverwritePlaycount, false).toBool();
  overwrite_rating_ = s.value(CollectionSettings::kOverwriteRating, false).toBool();
  s.endGroup();

  best_art_filters_.clear();
  for (const QString &filter : filters) {
    QString str = filter.trimmed();
    if (!str.isEmpty()) best_art_filters_ << str;
  }

  if (!monitor_ && was_monitoring_before) {
    fs_watcher_->Clear();
  }
  else if (monitor_ && !was_monitoring_before) {
    // Add all directories to all QFileSystemWatchers again
    for (const CollectionDirectory &dir : std::as_const(watched_dirs_)) {
      const CollectionSubdirectoryList subdirs = backend_->SubdirsInDirectory(dir.id);
      for (const CollectionSubdirectory &subdir : subdirs) {
        AddWatch(dir, subdir.path);
      }
    }
  }

  if (source_ == Song::Source::Collection) {
    if (monitor_ && scan_on_startup_ && mark_songs_unavailable_ && !periodic_scan_timer_->isActive()) {
      periodic_scan_timer_->start();
    }
    else if ((!monitor_ || !scan_on_startup_ || !mark_songs_unavailable_) && periodic_scan_timer_->isActive()) {
      periodic_scan_timer_->stop();
    }
  }

}

CollectionWatcher::ScanTransaction::ScanTransaction(CollectionWatcher *watcher, const int dir_id, const bool incremental, const bool ignores_mtime)
    : watcher_(watcher),
      dir_id_(dir_id),
      incremental_(incremental),
      ignores_mtime_(ignores_mtime),
      mark_songs_unavailable_(watcher->mark_songs_unavailable_),
      expire_unavailable_songs_days_(watcher->expire_unavailable_songs_days_),
      progress_max_(0),
      progress_(0),
      cached_songs_dirty_(true),
      cached_songs_missing_fingerprint_dirty_(true),
      cached_songs_missing_loudness_characteristics_dirty_(true),
      known_subdirs_dirty_(true) {

  QString description;

  if (watcher_->device_name_.isEmpty()) {
    description = tr("Updating collection");
  }
  else {
    description = tr("Updating %1").arg(watcher_->device_name_);
  }

  task_id_ = watcher_->task_manager_->StartTask(description);
  Q_EMIT watcher_->ScanStarted(task_id_);

}

CollectionWatcher::ScanTransaction::~ScanTransaction() {

  // If we're stopping then don't commit the transaction
  if (!watcher_->stop_or_abort_requested()) {
    CommitNewOrUpdatedSongs();
  }

  watcher_->task_manager_->SetTaskFinished(task_id_);

}

void CollectionWatcher::ScanTransaction::AddToProgress(const quint64 n) {

  progress_ += n;
  watcher_->task_manager_->SetTaskProgress(task_id_, progress_, progress_max_);

}

void CollectionWatcher::ScanTransaction::AddToProgressMax(const quint64 n) {

  progress_max_ += n;
  watcher_->task_manager_->SetTaskProgress(task_id_, progress_, progress_max_);

}

void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {

  if (!deleted_subdirs.isEmpty()) {
    Q_EMIT watcher_->SubdirsDeleted(deleted_subdirs);
  }

  if (!new_subdirs.isEmpty()) {
    Q_EMIT watcher_->SubdirsDiscovered(new_subdirs);
  }

  if (!touched_subdirs.isEmpty()) {
    Q_EMIT watcher_->SubdirsMTimeUpdated(touched_subdirs);
    touched_subdirs.clear();
  }

  if (!deleted_songs.isEmpty()) {
    if (mark_songs_unavailable_ && watcher_->source() == Song::Source::Collection) {
      Q_EMIT watcher_->SongsUnavailable(deleted_songs);
    }
    else {
      Q_EMIT watcher_->SongsDeleted(deleted_songs);
    }
    deleted_songs.clear();
  }

  if (!new_songs.isEmpty()) {
    Q_EMIT watcher_->NewOrUpdatedSongs(new_songs);
    new_songs.clear();
  }

  if (!touched_songs.isEmpty()) {
    Q_EMIT watcher_->SongsMTimeUpdated(touched_songs);
    touched_songs.clear();
  }

  if (!readded_songs.isEmpty()) {
    Q_EMIT watcher_->SongsReadded(readded_songs);
    readded_songs.clear();
  }

  for (const CollectionSubdirectory &subdir : std::as_const(deleted_subdirs)) {
    if (watcher_->watched_dirs_.contains(dir_id_)) {
      watcher_->RemoveWatch(watcher_->watched_dirs_[dir_id_], subdir);
    }
  }
  deleted_subdirs.clear();

  if (watcher_->monitor_) {
    for (const CollectionSubdirectory &subdir : std::as_const(new_subdirs)) {
      if (watcher_->watched_dirs_.contains(dir_id_)) {
        watcher_->AddWatch(watcher_->watched_dirs_[dir_id_], subdir.path);
      }
    }
  }
  new_subdirs.clear();

  if (incremental_ || ignores_mtime_) {
    Q_EMIT watcher_->UpdateLastSeen(dir_id_, expire_unavailable_songs_days_);
  }

}


SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QString &path) {

  if (cached_songs_dirty_) {
    const SongList songs = watcher_->backend_->FindSongsInDirectory(dir_id_);
    for (const Song &song : songs) {
      // Key by the NFC-normalized parent directory so a file that differs only in Unicode normalization (NFC vs NFD) between disk and database still matches
      const QString p = song.url().toLocalFile().normalized(QString::NormalizationForm_C).section(u'/', 0, -2);
      cached_songs_.insert(p, song);
    }
    cached_songs_dirty_ = false;
  }

  const QString nfc_path = path.normalized(QString::NormalizationForm_C);
  if (cached_songs_.contains(nfc_path)) {
    return cached_songs_.values(nfc_path);
  }

  return SongList();

}

bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QString &path) {

  if (cached_songs_missing_fingerprint_dirty_) {
    const SongList songs = watcher_->backend_->SongsWithMissingFingerprint(dir_id_);
    for (const Song &song : songs) {
      const QString p = song.url().toLocalFile().section(u'/', 0, -2);
      cached_songs_missing_fingerprint_.insert(p, song);
    }
    cached_songs_missing_fingerprint_dirty_ = false;
  }

  return cached_songs_missing_fingerprint_.contains(path);

}

bool CollectionWatcher::ScanTransaction::HasSongsWithMissingLoudnessCharacteristics(const QString &path) {

  if (cached_songs_missing_loudness_characteristics_dirty_) {
    const SongList songs = watcher_->backend_->SongsWithMissingLoudnessCharacteristics(dir_id_);
    for (const Song &song : songs) {
      const QString p = song.url().toLocalFile().section(u'/', 0, -2);
      cached_songs_missing_loudness_characteristics_.insert(p, song);
    }
    cached_songs_missing_loudness_characteristics_dirty_ = false;
  }

  return cached_songs_missing_loudness_characteristics_.contains(path);

}

void CollectionWatcher::ScanTransaction::SetKnownSubdirs(const CollectionSubdirectoryList &subdirs) {

  known_subdirs_ = subdirs;
  known_subdirs_dirty_ = false;

}

bool CollectionWatcher::ScanTransaction::HasSeenSubdir(const QString &path) {

  if (known_subdirs_dirty_) {
    SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_));
  }

  return std::any_of(known_subdirs_.begin(), known_subdirs_.end(), [path](const CollectionSubdirectory &subdir) { return subdir.path == path && subdir.mtime != 0; });

}

bool CollectionWatcher::ScanTransaction::HasScannedPath(const QString &path) {

  return scanned_paths_.contains(path);

}

void CollectionWatcher::ScanTransaction::MarkPathScanned(const QString &path) {

  scanned_paths_.insert(path);

}

CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdirs(const QString &path) {

  if (known_subdirs_dirty_) {
    SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_));
  }

  CollectionSubdirectoryList ret;
  for (const CollectionSubdirectory &subdir : std::as_const(known_subdirs_)) {
    if (subdir.path.left(subdir.path.lastIndexOf(QDir::separator())) == path && subdir.mtime != 0) {
      ret << subdir;
    }
  }

  return ret;

}

CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetAllSubdirs() {

  if (known_subdirs_dirty_) {
    SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_));
  }

  return known_subdirs_;

}

void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const CollectionSubdirectoryList &subdirs) {

  {
    const QFileInfo path_info(dir.path);
    if (path_info.isSymbolicLink()) {
      const QStorageInfo storage_info(path_info.symLinkTarget());
      if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
        qLog(Warning) << "Ignoring collection directory path" << dir.path << "which is a symbolic link to path" << path_info.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
        return;
      }
    }
    else {
      const QStorageInfo storage_info(dir.path);
      if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
        qLog(Warning) << "Ignoring collection directory path" << dir.path << "with rejected filesystem type" << storage_info.fileSystemType();
        return;
      }
    }
  }

  CancelStop();

  watched_dirs_[dir.id] = dir;

  if (subdirs.isEmpty()) {
    // This is a new directory that we've never seen before. Scan it fully.
    ScanTransaction transaction(this, dir.id, false, false);
    const quint64 files_count = FilesCountForPath(&transaction, dir.path);
    transaction.SetKnownSubdirs(subdirs);
    transaction.AddToProgressMax(files_count);
    ScanSubdirectory(dir, dir.path, CollectionSubdirectory(), files_count, &transaction);
    last_scan_time_ = QDateTime::currentSecsSinceEpoch();
  }
  else {
    if (monitor_) {
      for (const CollectionSubdirectory &subdir : subdirs) {
        AddWatch(dir, subdir.path);
      }
    }
    if (scan_on_startup_) {
      // We can do an incremental scan - looking at the mtimes of each subdirectory and only rescan if the directory has changed.
      ScanTransaction transaction(this, dir.id, true, false);
      QMap<QString, quint64> subdir_files_count;
      const quint64 files_count = FilesCountForSubdirs(&transaction, subdirs, subdir_files_count);
      transaction.SetKnownSubdirs(subdirs);
      transaction.AddToProgressMax(files_count);
      for (const CollectionSubdirectory &subdir : subdirs) {
        if (stop_or_abort_requested()) break;
        ScanSubdirectory(dir, subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
      }
      if (!stop_or_abort_requested()) {
        last_scan_time_ = QDateTime::currentSecsSinceEpoch();
      }
    }
  }

  Q_EMIT CompilationsNeedUpdating();

}

void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {

  // A renamed directory can be reached both from its own queued change notification and from its parent's vanished-child check below, so skip it if it was already scanned in this transaction to avoid deleting/adding its songs twice.
  if (t->HasScannedPath(path)) {
    t->AddToProgress(files_count);
    return;
  }
  t->MarkPathScanned(path);

  const QFileInfo path_info(path);
  const qint64 path_mtime = path_info.exists() && path_info.lastModified().isValid() ? path_info.lastModified().toSecsSinceEpoch() : 0;

  if (path_info.isSymLink()) {
    const QString real_path = path_info.symLinkTarget();
    const QStorageInfo storage_info(real_path);
    if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
      qLog(Warning) << "Ignoring symbolic link" << path << "which links to" << real_path << "with rejected filesystem type" << storage_info.fileSystemType();
      return;
    }
    // Do not scan symlinked dirs that are already in collection
    for (const CollectionDirectory &i : std::as_const(watched_dirs_)) {
      if (real_path == i.path || real_path.startsWith(i.path + u'/')) {
        return;
      }
    }
  }
  else {
    const QStorageInfo storage_info(path);
    if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
      qLog(Warning) << "Ignoring path" << path << "with rejected filesystem type" << storage_info.fileSystemType();
      return;
    }
  }

  bool songs_missing_fingerprint = false;
  bool songs_missing_loudness_characteristics = false;
#ifdef HAVE_SONGFINGERPRINTING
  if (song_tracking_) {
    songs_missing_fingerprint = t->HasSongsWithMissingFingerprint(path);
  }
#endif
#ifdef HAVE_EBUR128
  if (song_ebur128_loudness_analysis_) {
    songs_missing_loudness_characteristics = t->HasSongsWithMissingLoudnessCharacteristics(path);
  }
#endif

  if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && path_mtime != 0 && subdir.mtime == path_mtime && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) {
    // The directory hasn't changed since last time
    t->AddToProgress(files_count);
    return;
  }

  QMap<QString, QStringList> album_art;
  QStringList files_on_disk;
  CollectionSubdirectoryList my_new_subdirs;

  // If a directory is moved then only its parent gets a changed notification, so we need to look and see if any of our children don't exist anymore.
  // If one has been removed, "rescan" it to get the deleted songs
  const CollectionSubdirectoryList previous_subdirs = t->GetImmediateSubdirs(path);
  for (const CollectionSubdirectory &prev_subdir : previous_subdirs) {
    if (!QFile::exists(prev_subdir.path) && prev_subdir.path != path) {
      ScanSubdirectory(dir, prev_subdir.path, prev_subdir, 0, t, true);
    }
  }

  // First we "quickly" get a list of the files in the directory that we think might be music.  While we're here, we also look for new subdirectories and possible album artwork.
  if (path_info.exists()) {
    QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
    while (it.hasNext()) {

      if (stop_or_abort_requested()) return;

      const QString child_filepath = it.next();
      const QFileInfo child_fileinfo(child_filepath);

      if (child_fileinfo.isSymLink()) {
        const QStorageInfo storage_info(child_fileinfo.symLinkTarget());
        if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
          qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
          continue;
        }
      }

      if (child_fileinfo.isDir()) {
        if (!t->HasSeenSubdir(child_filepath)) {
          // We haven't seen this subdirectory before - add it to a list, and later we'll tell the backend about it and scan it.
          CollectionSubdirectory new_subdir;
          new_subdir.directory_id = -1;
          new_subdir.path = child_filepath;
          new_subdir.mtime = child_fileinfo.exists() && child_fileinfo.lastModified().isValid() ? child_fileinfo.lastModified().toSecsSinceEpoch() : 0;
          my_new_subdirs << new_subdir;
        }
        t->AddToProgress(1);
      }
      else {
        const QString ext_part = ExtensionPart(child_filepath);
        const QString dir_part = DirectoryPart(child_filepath);
        if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
          t->AddToProgress(1);
        }
        else if (sValidImages.contains(ext_part)) {
          album_art[dir_part] << child_filepath;
          t->AddToProgress(1);
        }
        else {
          files_on_disk << child_filepath;
        }
      }
    }
  }

  if (stop_or_abort_requested()) return;

  // Ask the database for a list of files in this directory
  const SongList songs_in_db = t->FindSongsInSubdirectory(path);

  QSet<QString> cues_processed;

  // Now compare the list from the database with the list of files on disk
  const QStringList files_on_disk_copy = files_on_disk;
  for (const QString &file : files_on_disk_copy) {

    if (stop_or_abort_requested()) return;

    // Associated CUE
    const QString new_cue = CueParser::FindCueFilename(file);

    SongList matching_songs;
    if (FindSongsByPath(songs_in_db, file, &matching_songs)) {  // Found matching song in DB by path.

      const Song matching_song = matching_songs.first();

      // The song is in the database and still on disk.
      // Check the mtime to see if it's been changed since it was added.
      const QFileInfo fileinfo(file);

      if (!fileinfo.exists()) {
        // Partially fixes race condition - if file was removed between being added to the list and now.
        files_on_disk.removeAll(file);
        t->AddToProgress(1);
        continue;
      }

      // CUE sheet's path from collection (if any).
      qint64 matching_song_cue_mtime = static_cast<qint64>(GetMtimeForCue(matching_song.cue_path()));

      // CUE sheet's path from this file (if any).
      qint64 new_cue_mtime = 0;
      if (!new_cue.isEmpty()) {
        new_cue_mtime = static_cast<qint64>(GetMtimeForCue(new_cue));
      }

      const bool cue_added = new_cue_mtime != 0 && !matching_song.has_cue();
      const bool cue_changed = new_cue_mtime != 0 && matching_song.has_cue() && new_cue != matching_song.cue_path();
      const bool cue_deleted = matching_song.has_cue() && new_cue_mtime == 0;

      // Watch out for CUE songs which have their mtime equal to qMax(media_file_mtime, cue_sheet_mtime)
      bool changed = (matching_song.mtime() != qMax(fileinfo.lastModified().toSecsSinceEpoch(), matching_song_cue_mtime)) || cue_deleted || cue_added || cue_changed;

      // Also want to look to see whether the album art has changed
      const QUrl art_automatic = ArtForSong(file, album_art);
      if (matching_song.art_automatic() != art_automatic || (!matching_song.art_automatic().isEmpty() && !matching_song.art_automatic_is_valid())) {
        changed = true;
      }

      bool missing_fingerprint = false;
      bool missing_loudness_characteristics = false;
#ifdef HAVE_SONGFINGERPRINTING
      if (song_tracking_ && matching_song.fingerprint().isEmpty()) {
        missing_fingerprint = true;
      }
#endif
#ifdef HAVE_EBUR128
      if (song_ebur128_loudness_analysis_ && (!matching_song.ebur128_integrated_loudness_lufs() || !matching_song.ebur128_loudness_range_lu())) {
        missing_loudness_characteristics = true;
      }
#endif

      if (changed) {
        qLog(Debug) << file << "has changed.";
      }
      else if (missing_fingerprint) {
        qLog(Debug) << file << "is missing fingerprint.";
      }
      else if (missing_loudness_characteristics) {
        qLog(Debug) << file << "is missing EBU R 128 loudness characteristics.";
      }

      // If the song is unavailable and nothing has changed, just mark it as available without re-scanning
      // For CUE files with multiple sections, all sections share the same file and would have the same availability status
      if (matching_song.unavailable() && !changed && !missing_fingerprint && !missing_loudness_characteristics) {
        qLog(Debug) << "Unavailable song" << file << "restored without re-scanning.";
        t->readded_songs << matching_songs;
      }
      // The song's changed or missing fingerprint - create fingerprint and reread the metadata from file.
      else if (t->ignores_mtime() || changed || missing_fingerprint || missing_loudness_characteristics) {

        QString fingerprint;
#ifdef HAVE_SONGFINGERPRINTING
        if (song_tracking_) {
          Chromaprinter chromaprinter(file);
          fingerprint = chromaprinter.CreateFingerprint();
          if (fingerprint.isEmpty()) {
            fingerprint = "NONE"_L1;
          }
        }
#endif

        if (new_cue.isEmpty() || new_cue_mtime == 0) {  // If no CUE or it's about to lose it.
          if (!UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, cue_deleted, t)) {
            files_on_disk.removeAll(file);
          }
        }
        else {  // If CUE associated.
          UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, art_automatic, matching_songs, t);
        }
      }

    }
    else {  // Search the DB by fingerprint.
      QString fingerprint;
#ifdef HAVE_SONGFINGERPRINTING
      if (song_tracking_) {
        Chromaprinter chromaprinter(file);
        fingerprint = chromaprinter.CreateFingerprint();
        if (fingerprint.isEmpty()) {
          fingerprint = "NONE"_L1;
        }
      }
#endif
      if (song_tracking_ && !fingerprint.isEmpty() && fingerprint != "NONE"_L1 && FindSongsByFingerprint(file, fingerprint, &matching_songs)) {

        // The song is in the database and still on disk.
        // Check the mtime to see if it's been changed since it was added.
        const QFileInfo fileinfo(file);
        if (!fileinfo.exists()) {
          // Partially fixes race condition - if file was removed between being added to the list and now.
          files_on_disk.removeAll(file);
          t->AddToProgress(1);
          continue;
        }

        // Make sure the songs aren't deleted, as they still exist elsewhere with a different file path.
        bool matching_songs_has_cue = false;
        for (const Song &matching_song : std::as_const(matching_songs)) {
          const QString matching_filename = matching_song.url().toLocalFile();
          if (!t->files_changed_path_.contains(matching_filename)) {
            t->files_changed_path_ << matching_filename;
            qLog(Debug) << matching_filename << "has changed path to" << file;
          }
          if (t->deleted_songs.contains(matching_song)) {
            t->deleted_songs.removeAll(matching_song);
          }
          if (matching_song.has_cue()) {
            matching_songs_has_cue = true;
          }
        }

        // CUE sheet's path from this file (if any).
        qint64 new_cue_mtime = 0;
        if (!new_cue.isEmpty()) {
          new_cue_mtime = static_cast<qint64>(GetMtimeForCue(new_cue));
        }

        // Get new album art
        const QUrl art_automatic = ArtForSong(file, album_art);

        if (new_cue.isEmpty() || new_cue_mtime == 0) {  // If no CUE or it's about to lose it.
          if (!UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, matching_songs_has_cue && new_cue_mtime == 0, t)) {
            files_on_disk.removeAll(file);
          }
        }
        else {  // If CUE associated.
          UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, art_automatic, matching_songs, t);
        }

      }
      else {  // The song is on disk but not in the DB

        const SongList songs = ScanNewFile(file, path, fingerprint, new_cue, &cues_processed);
        if (songs.isEmpty()) {
          files_on_disk.removeAll(file);
          t->AddToProgress(1);
          continue;
        }

        qLog(Debug) << file << "is new.";

        // Choose art for the song(s)
        const QUrl art_automatic = ArtForSong(file, album_art);

        for (Song song : songs) {
          song.set_directory_id(t->dir_id());
          if (song.art_automatic().isEmpty()) song.set_art_automatic(art_automatic);
          t->new_songs << song;
        }
      }
    }
    t->AddToProgress(1);
  }

  // Look for deleted songs.
  // files_on_disk holds the on-disk path spelling while the database stores its own; the two can differ purely by Unicode normalization form (NFC vs NFD).
  // Compare in NFC so a song that was just matched (FindSongsByPath normalizes too) is not also treated as deleted within the same scan.
  QSet<QString> files_on_disk_nfc;
  files_on_disk_nfc.reserve(files_on_disk.count());
  for (const QString &file : std::as_const(files_on_disk)) {
    files_on_disk_nfc.insert(file.normalized(QString::NormalizationForm_C));
  }
  for (const Song &song : std::as_const(songs_in_db)) {
    const QString file = song.url().toLocalFile();
    if (!song.unavailable() && !files_on_disk_nfc.contains(file.normalized(QString::NormalizationForm_C)) && !t->files_changed_path_.contains(file)) {
      qLog(Debug) << "Song deleted from disk:" << file;
      t->deleted_songs << song;
    }
  }

  // Add, update or delete subdir
  CollectionSubdirectory updated_subdir;
  updated_subdir.directory_id = t->dir_id();
  updated_subdir.mtime = path_mtime;
  updated_subdir.path = path;

  if (!path_info.exists() && updated_subdir.path != dir.path) {
    t->deleted_subdirs << updated_subdir;
  }
  else if (subdir.directory_id == -1) {
    t->new_subdirs << updated_subdir;
  }
  else if (subdir.mtime != updated_subdir.mtime) {
    t->touched_subdirs << updated_subdir;
  }

  // Recurse into the new subdirs that we found
  for (const CollectionSubdirectory &my_new_subdir : std::as_const(my_new_subdirs)) {
    if (stop_or_abort_requested()) return;
    ScanSubdirectory(dir, my_new_subdir.path, my_new_subdir, 0, t, true);
  }

}

void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file,
                                                 const QString &path,
                                                 const QString &fingerprint,
                                                 const QString &matching_cue,
                                                 const QUrl &art_automatic,
                                                 const SongList &old_cue_songs,
                                                 ScanTransaction *t) const {

  QHash<quint64, Song> sections_map;
  for (const Song &song : old_cue_songs) {
    sections_map.insert(static_cast<quint64>(song.beginning_nanosec()), song);
  }

  // Load new CUE songs
  QFile cue_file(matching_cue);
  if (!cue_file.exists()) return;
  if (!cue_file.open(QIODevice::ReadOnly)) {
    qLog(Error) << "Could not open CUE file" << matching_cue << "for reading:" << cue_file.errorString();
    return;
  }
  const SongList songs = cue_parser_->Load(&cue_file, matching_cue, path, false).songs;
  cue_file.close();

  // Update every song that's in the CUE and collection
  QSet<int> used_ids;
  for (Song new_cue_song : songs) {
    new_cue_song.set_source(source_);
    new_cue_song.set_directory_id(t->dir_id());
    PerformEBUR128Analysis(new_cue_song);
    new_cue_song.set_fingerprint(fingerprint);

    if (sections_map.contains(static_cast<quint64>(new_cue_song.beginning_nanosec()))) {  // Changed section
      const Song matching_cue_song = sections_map[static_cast<quint64>(new_cue_song.beginning_nanosec())];
      new_cue_song.set_id(matching_cue_song.id());
      new_cue_song.set_art_automatic(art_automatic);
      new_cue_song.MergeUserSetData(matching_cue_song, true, true);
      AddChangedSong(file, matching_cue_song, new_cue_song, t);
      used_ids.insert(matching_cue_song.id());
    }
    else {  // A new section
      t->new_songs << new_cue_song;
    }
  }

  // Sections that are now missing
  for (const Song &old_cue : old_cue_songs) {
    if (!used_ids.contains(old_cue.id())) {
      t->deleted_songs << old_cue;
    }
  }

}

bool CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
                                                   const QString &fingerprint,
                                                   const SongList &matching_songs,
                                                   const QUrl &art_automatic,
                                                   const bool cue_deleted,
                                                   ScanTransaction *t) {

  // If a CUE got deleted, we turn it's first section into the new 'raw' (cueless) song, and we just remove the rest of the sections from the collection
  const Song &matching_song = matching_songs.first();
  if (cue_deleted) {
    for (const Song &song : matching_songs) {
      if (!song.IsAllMetadataEqual(matching_song)) {
        t->deleted_songs << song;
      }
    }
  }

  Song song_on_disk(source_);
  const TagReaderResult result = tagreader_client_->ReadFileBlocking(file, &song_on_disk);
  if (result.success() && song_on_disk.is_valid()) {
    song_on_disk.set_source(source_);
    song_on_disk.set_directory_id(t->dir_id());
    song_on_disk.set_id(matching_song.id());
    PerformEBUR128Analysis(song_on_disk);
    song_on_disk.set_fingerprint(fingerprint);
    song_on_disk.set_art_automatic(art_automatic);
    song_on_disk.MergeUserSetData(matching_song, !overwrite_playcount_, !overwrite_rating_);
    AddChangedSong(file, matching_song, song_on_disk, t);
  }

  return result.success() && song_on_disk.is_valid();

}

SongList CollectionWatcher::ScanNewFile(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, QSet<QString> *cues_processed) const {

  SongList songs;

  quint64 matching_cue_mtime = GetMtimeForCue(matching_cue);
  if (matching_cue_mtime != 0) {  // If it's a CUE - create virtual tracks

    // Don't process the same CUE many times
    if (cues_processed->contains(matching_cue)) return songs;

    QFile cue_file(matching_cue);
    if (!cue_file.exists()) return songs;

    if (!cue_file.open(QIODevice::ReadOnly)) {
      qLog(Error) << "Could not open CUE file" << matching_cue << "for reading:" << cue_file.errorString();
      return songs;
    }

    // Ignore FILEs pointing to other media files.
    // Also, watch out for incorrect media files.
    // Playlist parser for CUEs considers every entry in sheet valid, and we don't want invalid media getting into collection!
    QString file_nfd = file.normalized(QString::NormalizationForm_D);
    SongList cue_songs = cue_parser_->Load(&cue_file, matching_cue, path, false).songs;
    cue_file.close();
    songs.reserve(cue_songs.count());
    for (Song &cue_song : cue_songs) {
      cue_song.set_source(source_);
      PerformEBUR128Analysis(cue_song);
      cue_song.set_fingerprint(fingerprint);
      if (cue_song.url().toLocalFile().normalized(QString::NormalizationForm_D) == file_nfd) {
        songs << cue_song;
      }
    }
    if (!songs.isEmpty()) {
      *cues_processed << matching_cue;
    }
  }
  else {  // It's a normal media file
    Song song(source_);
    const TagReaderResult result = tagreader_client_->ReadFileBlocking(file, &song);
    if (result.success() && song.is_valid()) {
      song.set_source(source_);
      PerformEBUR128Analysis(song);
      song.set_fingerprint(fingerprint);
      songs << song;
    }
  }

  return songs;

}

void CollectionWatcher::AddChangedSong(const QString &file, const Song &matching_song, const Song &new_song, ScanTransaction *t) {

  bool notify_new = false;
  QStringList changes;

  if (matching_song.unavailable()) {
    qLog(Debug) << "Unavailable song" << file << "restored.";
    notify_new = true;
  }
  else {
    if (matching_song.url() != new_song.url()) {
      changes << u"file path"_s;
      notify_new = true;
    }
    if (matching_song.filetype() != new_song.filetype()) {
      changes << u"filetype"_s;
      notify_new = true;
    }
    if (matching_song.filesize() != new_song.filesize()) {
      changes << u"filesize"_s;
      notify_new = true;
    }
    if (matching_song.length_nanosec() != new_song.length_nanosec()) {
      changes << u"length"_s;
      notify_new = true;
    }
    if (matching_song.fingerprint() != new_song.fingerprint()) {
      changes << u"fingerprint"_s;
      notify_new = true;
    }
    if (!matching_song.IsMetadataEqual(new_song)) {
      changes << u"metadata"_s;
      notify_new = true;
    }
    if (!matching_song.IsPlayStatisticsEqual(new_song)) {
      changes << u"play statistics"_s;
      notify_new = true;
    }
    if (!matching_song.IsRatingEqual(new_song)) {
      changes << u"rating"_s;
      notify_new = true;
    }
    if (!matching_song.IsArtEqual(new_song)) {
      changes << u"album art"_s;
      notify_new = true;
    }
    if (!matching_song.IsAcoustIdEqual(new_song)) {
      changes << u"acoustid"_s;
      notify_new = true;
    }
    if (!matching_song.IsMusicBrainzEqual(new_song)) {
      changes << u"musicbrainz"_s;
      notify_new = true;
    }
    if (!matching_song.IsEBUR128Equal(new_song)) {
      changes << u"ebur128 loudness characteristics"_s;
      notify_new = true;
    }
    if (matching_song.mtime() != new_song.mtime()) {
      changes << u"mtime"_s;
    }
    if (matching_song.ctime() != new_song.ctime()) {
      changes << u"ctime"_s;
    }

    if (changes.isEmpty()) {
      qLog(Debug) << "Song" << file << "unchanged.";
    }
    else {
      qLog(Debug) << "Song" << file << changes.join(", "_L1) << "changed.";
    }

  }

  if (notify_new) {
    t->new_songs << new_song;
  }
  else {
    t->touched_songs << new_song;
  }

}

void CollectionWatcher::PerformEBUR128Analysis(Song &song) const {

  if (!song_ebur128_loudness_analysis_) return;

#ifdef HAVE_EBUR128
  std::optional<EBUR128Measures> loudness_characteristics = EBUR128Analysis::Compute(song);
  if (loudness_characteristics) {
    song.set_ebur128_integrated_loudness_lufs(loudness_characteristics->loudness_lufs);
    song.set_ebur128_loudness_range_lu(loudness_characteristics->range_lu);
  }
#else
  Q_UNUSED(song)
#endif

}

quint64 CollectionWatcher::GetMtimeForCue(const QString &cue_path) {

  if (cue_path.isEmpty()) {
    return 0;
  }

  const QFileInfo fileinfo(cue_path);
  if (!fileinfo.exists()) {
    return 0;
  }

  const QDateTime cue_last_modified = fileinfo.lastModified();

  return cue_last_modified.isValid() ? static_cast<quint64>(cue_last_modified.toSecsSinceEpoch()) : 0;

}

void CollectionWatcher::AddWatch(const CollectionDirectory &dir, const QString &path) {

  if (!QFile::exists(path)) return;

  fs_watcher_->AddPath(path);
  subdir_mapping_[path] = dir;

}

void CollectionWatcher::RemoveWatch(const CollectionDirectory &dir, const CollectionSubdirectory &subdir) {

  const QStringList subdir_paths = subdir_mapping_.keys(dir);
  for (const QString &subdir_path : subdir_paths) {
    if (subdir_path != subdir.path) continue;
    fs_watcher_->RemovePath(subdir_path);
    subdir_mapping_.remove(subdir_path);
    break;
  }

}

void CollectionWatcher::RemoveDirectory(const CollectionDirectory &dir) {

  rescan_queue_.remove(dir.id);
  watched_dirs_.remove(dir.id);

  // Stop watching the directory's subdirectories
  const QStringList subdir_paths = subdir_mapping_.keys(dir);
  for (const QString &subdir_path : subdir_paths) {
    fs_watcher_->RemovePath(subdir_path);
    subdir_mapping_.remove(subdir_path);
  }

}

bool CollectionWatcher::FindSongsByPath(const SongList &songs, const QString &path, SongList *out) {

  // Compare on the NFC-normalized path so a file that differs only in Unicode normalization (NFC vs NFD) between disk and database still matches, instead of being treated as a new file and duplicated.
  const QString nfc_path = path.normalized(QString::NormalizationForm_C);
  for (const Song &song : songs) {
    if (song.url().toLocalFile().normalized(QString::NormalizationForm_C) == nfc_path) {
      *out << song;
    }
  }

  return !out->isEmpty();

}

bool CollectionWatcher::FindSongsByFingerprint(const QString &file, const QString &fingerprint, SongList *out) {

  const SongList songs = backend_->GetSongsByFingerprint(fingerprint);
  for (const Song &song : songs) {
    QString filename = song.url().toLocalFile();
    QFileInfo info(filename);
    // Allow multiple songs in different directories with the same fingerprint.
    // Only use the matching song by fingerprint if it doesn't already exist in a different path.
    if (file == filename || !info.exists()) {
      *out << song;
    }
  }

  return !out->isEmpty();

}

bool CollectionWatcher::FindSongsByFingerprint(const QString &file, const SongList &songs, const QString &fingerprint, SongList *out) {

  for (const Song &song : songs) {
    QString filename = song.url().toLocalFile();
    if (song.fingerprint() == fingerprint && (file == filename || !QFileInfo::exists(filename))) {
      *out << song;
      return true;
    }
  }

  return !out->isEmpty();

}

void CollectionWatcher::DirectoryChanged(const QString &subdir) {

  QHash<QString, CollectionDirectory>::const_iterator it = subdir_mapping_.constFind(subdir);
  if (it == subdir_mapping_.constEnd()) {
    return;
  }
  const CollectionDirectory dir = *it;

  qLog(Debug) << "Subdir" << subdir << "changed under directory" << dir.path << "id" << dir.id;

  // Queue the subdir for rescanning
  if (!rescan_queue_[dir.id].contains(subdir)) rescan_queue_[dir.id] << subdir;

  if (!rescan_paused_) rescan_timer_->start();

}

void CollectionWatcher::RescanPathsNow() {

  const QList<int> dir_ids = rescan_queue_.keys();
  for (const int dir_id : dir_ids) {

    if (stop_or_abort_requested()) break;
    ScanTransaction transaction(this, dir_id, false, false);

    const QStringList paths = rescan_queue_.value(dir_id);

    QMap<QString, quint64> subdir_files_count;
    for (const QString &path : paths) {
      const quint64 files_count = FilesCountForPath(&transaction, path);
      subdir_files_count[path] = files_count;
      transaction.AddToProgressMax(files_count);
    }

    for (const QString &path : paths) {
      if (stop_or_abort_requested()) break;
      if (!subdir_mapping_.contains(path)) {
        continue;
      }
      CollectionSubdirectory subdir;
      subdir.directory_id = dir_id;
      subdir.mtime = 0;
      subdir.path = path;
      ScanSubdirectory(subdir_mapping_[path], path, subdir, subdir_files_count[path], &transaction);
    }
  }

  rescan_queue_.clear();

  Q_EMIT CompilationsNeedUpdating();

}

QString CollectionWatcher::PickBestArt(const QStringList &art_automatic_list) {

  // This is used when there is more than one image in a directory.
  // Pick the biggest image that matches the most important filter

  QStringList filtered;

  for (const QString &filter_text : std::as_const(best_art_filters_)) {
    // The images in the images list are represented by a full path, so we need to isolate just the filename
    for (const QString &art_automatic : art_automatic_list) {
      QFileInfo fileinfo(art_automatic);
      QString filename(fileinfo.fileName());
      if (filename.contains(filter_text, Qt::CaseInsensitive))
        filtered << art_automatic;
    }

    // We assume the filters are give in the order best to worst, so if we've got a result, we go with it.
    // Otherwise we might start capturing more generic rules
    if (!filtered.isEmpty()) break;
  }

  if (filtered.isEmpty()) {
    // The filter was too restrictive, just use the original list
    filtered = art_automatic_list;
  }

  int biggest_size = 0;
  QString biggest_path;

  for (const QString &path : std::as_const(filtered)) {
    if (stop_or_abort_requested()) break;

    QImage image(path);
    if (image.isNull()) continue;

    int size = image.width() * image.height();
    if (size > biggest_size) {
      biggest_size = size;
      biggest_path = path;
    }
  }

  return biggest_path;

}

QUrl CollectionWatcher::ArtForSong(const QString &path, QMap<QString, QStringList> &art_automatic_list) {

  QString dir(DirectoryPart(path));

  if (art_automatic_list.contains(dir)) {
    if (art_automatic_list[dir].count() == 1) {
      return QUrl::fromLocalFile(art_automatic_list[dir][0]);
    }
    else {
      const QString best_art = PickBestArt(art_automatic_list[dir]);
      art_automatic_list[dir] = QStringList() << best_art;
      return QUrl::fromLocalFile(best_art);
    }
  }

  return QUrl();

}

void CollectionWatcher::SetRescanPausedAsync(bool pause) {

  QMetaObject::invokeMethod(this, "SetRescanPaused", Qt::QueuedConnection, Q_ARG(bool, pause));

}

void CollectionWatcher::SetRescanPaused(bool pause) {

  rescan_paused_ = pause;
  if (!rescan_paused_ && !rescan_queue_.isEmpty()) RescanPathsNow();

}

void CollectionWatcher::IncrementalScanAsync() {

  QMetaObject::invokeMethod(this, &CollectionWatcher::IncrementalScanNow, Qt::QueuedConnection);

}

void CollectionWatcher::FullScanAsync() {

  QMetaObject::invokeMethod(this, &CollectionWatcher::FullScanNow, Qt::QueuedConnection);

}

void CollectionWatcher::IncrementalScanCheck() {

  qint64 duration = QDateTime::currentSecsSinceEpoch() - last_scan_time_;
  if (duration >= 86400) {
    qLog(Debug) << "Performing periodic incremental scan.";
    IncrementalScanNow();
  }

}

void CollectionWatcher::IncrementalScanNow() { PerformScan(true, false); }

void CollectionWatcher::FullScanNow() { PerformScan(false, true); }

void CollectionWatcher::PerformScan(const bool incremental, const bool ignore_mtimes) {

  CancelStop();

  for (const CollectionDirectory &dir : std::as_const(watched_dirs_)) {

    if (stop_or_abort_requested()) break;

    ScanTransaction transaction(this, dir.id, incremental, ignore_mtimes);
    CollectionSubdirectoryList subdirs = transaction.GetAllSubdirs();

    const bool has_collection_root_dir = std::any_of(subdirs.begin(), subdirs.end(), [&dir](const CollectionSubdirectory &subdir) { return subdir.path == dir.path; });
    if (!has_collection_root_dir) {
      qLog(Debug) << "Collection directory wasn't in subdir list, re-adding";
      CollectionSubdirectory subdir;
      subdir.directory_id = dir.id;
      subdir.path = dir.path;
      subdir.mtime = 0;
      subdirs << subdir;
    }

    QMap<QString, quint64> subdir_files_count;
    quint64 files_count = FilesCountForSubdirs(&transaction, subdirs, subdir_files_count);
    transaction.AddToProgressMax(files_count);

    for (const CollectionSubdirectory &subdir : std::as_const(subdirs)) {
      if (stop_or_abort_requested()) break;
      ScanSubdirectory(dir, subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
    }

  }

  last_scan_time_ = QDateTime::currentSecsSinceEpoch();

  Q_EMIT CompilationsNeedUpdating();

}

quint64 CollectionWatcher::FilesCountForPath(ScanTransaction *t, const QString &path) {

  const QFileInfo path_info(path);
  if (path_info.isSymLink()) {
    const QString real_path = path_info.symLinkTarget();
    const QStorageInfo storage_info(real_path);
    if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
      return 0;
    }
    for (const CollectionDirectory &dir : std::as_const(watched_dirs_)) {
      if (real_path == dir.path || real_path.startsWith(dir.path + u'/')) {
        return 0;
      }
    }
  }
  else {
    const QStorageInfo storage_info(path);
    if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
      return 0;
    }
  }

  quint64 i = 0;
  QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
  while (it.hasNext()) {

    if (stop_or_abort_requested()) break;

    const QString child_filepath = it.next();
    const QFileInfo child_fileinfo(child_filepath);

    if (child_fileinfo.isDir()) {
      if (child_fileinfo.isSymLink()) {

        const QString real_path = child_fileinfo.symLinkTarget();

        QStorageInfo storage_info(real_path);
        if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
          continue;
        }

        bool points_into_watched_dir = false;
        for (const CollectionDirectory &dir : std::as_const(watched_dirs_)) {
          if (real_path == dir.path || real_path.startsWith(dir.path + u'/')) {
            points_into_watched_dir = true;
            break;
          }
        }
        // A symlink into an already-watched directory would recurse/double-count; skip it.
        if (points_into_watched_dir) continue;
      }

      if (!t->HasSeenSubdir(child_filepath) && !child_fileinfo.isHidden()) {
        // We haven't seen this subdirectory before, so we need to include the file count for this directory too.
        i += FilesCountForPath(t, child_filepath);
      }

    }

    ++i;

  }

  return i;

}

quint64 CollectionWatcher::FilesCountForSubdirs(ScanTransaction *t, const CollectionSubdirectoryList &subdirs, QMap<QString, quint64> &subdir_files_count) {

  quint64 i = 0;
  for (const CollectionSubdirectory &subdir : subdirs) {
    if (stop_or_abort_requested()) break;
    const quint64 files_count = FilesCountForPath(t, subdir.path);
    subdir_files_count[subdir.path] = files_count;
    i += files_count;
  }

  return i;

}

void CollectionWatcher::RescanSongsAsync(const SongList &songs) {

  QMetaObject::invokeMethod(this, "RescanSongs", Qt::QueuedConnection, Q_ARG(SongList, songs));

}

void CollectionWatcher::RescanSongs(const SongList &songs) {

  CancelStop();

  QStringList scanned_paths;
  for (const Song &song : songs) {
    if (stop_or_abort_requested()) break;
    if (!watched_dirs_.contains(song.directory_id())) continue;
    const CollectionDirectory dir = watched_dirs_.value(song.directory_id());
    const QString song_path = song.url().toLocalFile().section(u'/', 0, -2);
    if (scanned_paths.contains(song_path)) continue;
    ScanTransaction transaction(this, song.directory_id(), false, true);
    const CollectionSubdirectoryList subdirs = transaction.GetAllSubdirs();
    for (const CollectionSubdirectory &subdir : subdirs) {
      if (stop_or_abort_requested()) break;
      if (subdir.path != song_path) continue;
      qLog(Debug) << "Rescan for directory ID" << song.directory_id() << "directory" << subdir.path;
      const quint64 files_count = FilesCountForPath(&transaction, subdir.path);
      ScanSubdirectory(dir, song_path, subdir, files_count, &transaction);
      scanned_paths << subdir.path;
    }
  }

  Q_EMIT CompilationsNeedUpdating();

}
