English title: Does qBittorrent Directory Watch Support NTFS?

qBittorrent 是一个基于 rb_libtorrent库 的跨平台高性能BT客户端。

这个libtorrent 有一个前缀rb_的原因是,有一个叫做 RTorrent的软件已经占用了libtorrent这个名字。

而在qB和Deluge里面,通常大家所说的libtorrent ,全名是libtorrent-rasterbar, 也就是 RHEL包名里的rb_libtorrent

缘由

今天有群友说qB的目录监视功能对于NTFS文件系统下的目录不工作,然后我回复说, Linux下的inotify是无法支持NTFS文件系统的,并建议其更换为EXT4文件系统。 然后有群友反馈说,根据他的使用经验,qB Linux版对于NTFS文件的watch功能一直是工作正常的。当然,一开始我对此是表示怀疑的,但是此群友随即截图表示,测试了下,再次确认是工作的。

(先说下结果: 后面我也确认了,qBittorrent目录监视功能在Linux下是支持NTFS文件系统的, 那位监视功能不工作的群友,应该是配置不正确导致的)

当然,在此之前我并没有看过qB关于这一块的代码。于是我决定花些时间一探究竟。

qB的种子目录监视功能实现分析

qB是基于 qt5 开发的,而 qt5 的文件监视功能主要是由 QFileSystemWatcher 实现。

于是我又查看了一下QFileSystemWatcher的源码相关实现 https://github.com/qt/qtbase/blob/69795835f3a578f60b16f09943feee6326087342/src/corelib/io/qfilesystemwatcher.cpp#L50, 确认了在Linux下,QFileSystemWatcher实际上是基于inotify来实现的。

老灯没有看到任何文档表明inotify支持NTFS文件系统(荒野注:后续的测试表明,这个猜想是错误的。), 因为 libinotify 主要是基于inode 工作的, NTFS 并不存在 inode 这一概念。 同时这里有个类似的回复刚好证实老灯的猜测: https://bugs.launchpad.net/drapes/+bug/110117

既然 qt5 在 Linux 下是肯定不能 watch NTFS 文件系统的,那么我想 qB 肯定是采用了其它实现。(荒野注:后续的测试表明,这个猜想是错误的。)

很快定位到 qB 的相关源码 src/base/filesystemwatcher.cppsrc/base/filesystemwatcher.h

我们看一下添加监视目录的相关实现:

namespace
{
    // 监视定时器 间隔时间, 10秒(同时用于 网络文件系统 和 不完整种子 ,注意本地文件系统用的是singleShot, 并且间隔固定为2秒)
    const int WATCH_INTERVAL = 10000; // 10 sec

    // 不完整种子 检测次数限制, 超过5次
    const int MAX_PARTIAL_RETRIES = 5;
}

// 首先`FileSystemWatcher` 是继承自 `QFileSystemWatcher`的
FileSystemWatcher::FileSystemWatcher(QObject *parent)
    : QFileSystemWatcher(parent)
{
  // 来自 QFileSystemWatcher 的事件通知,当目录有变动时, 执行 scanLocalFolder
    connect(this, &QFileSystemWatcher::directoryChanged, this, &FileSystemWatcher::scanLocalFolder);

  // m_partialTorrentTimer 定时器用于处理“不完整种子”(主要的场景是,一个种子比较大,刚好在copy的时候被发现了,但是此时种子还没有write完)
  // 将 m_partialTorrentTimer 设置成single-shot timer, 这种定时器只会fire一次
    m_partialTorrentTimer.setSingleShot(true);
    connect(&m_partialTorrentTimer, &QTimer::timeout, this, &FileSystemWatcher::processPartialTorrents);

  // m_watchTimer 用于处理网络文件系统(比如nfs之类的)下的目录监视, 这个QTimer是会不断定时触发的(每10秒)
    connect(&m_watchTimer, &QTimer::timeout, this, &FileSystemWatcher::scanNetworkFolders);
}

void FileSystemWatcher::addPath(const QString &path)
{
    if (path.isEmpty()) return;

    // Q_OS_HAIKU 这个咱也不用管,没怎么听过的操作系统。
    // 由于我们这里讨论的是 Linux 系统,因此可以忽视这个 macro .
#if !defined Q_OS_HAIKU
    const QDir dir(path);
    // 目录不存在则直接返回
    if (!dir.exists()) return;

    // 针对网络文件系统处理
    // Check if the path points to a network file system or not
    if (Utils::Fs::isNetworkFileSystem(path)) {
        // Network mode
        LogMsg(tr("Watching remote folder: \"%1\"").arg(Utils::Fs::toNativePath(path)));
        m_watchedFolders << dir;

        // 新目录加入watch列表后,马上 启动 或 重启 timer
        m_watchTimer.start(WATCH_INTERVAL);

        // 返回
        return;
    }
#endif

    // 正常模式,针对本地文件系统
    // Normal mode
    LogMsg(tr("Watching local folder: \"%1\"").arg(Utils::Fs::toNativePath(path)));

    // 注意这里的addPath是QFileSystemWatcher的,因此,对于NTFS肯定是不生效
    QFileSystemWatcher::addPath(path);

    // 调用 scanLocalFolder 进行处理
    scanLocalFolder(path);
}

我们再看一下scanLocalFolder的实现:

void FileSystemWatcher::scanLocalFolder(const QString &path)
{
    // 直接启动一个single-shot timer, 这种定时器只会fire一次,时间为 2秒 种之后
    QTimer::singleShot(2000, this, [this, path]() { processTorrentsInDir(path); });
}

然后我们再看 processTorrentsInDir 的实现:

void FileSystemWatcher::processTorrentsInDir(const QDir &dir)
{
    QStringList torrents;
    const QStringList files = dir.entryList({"*.torrent", "*.magnet"}, QDir::Files);
    for (const QString &file : files) {
        const QString fileAbsPath = dir.absoluteFilePath(file);
        // .magnet 后缀的文件,主要是用于 Vuze 客户端的一种文件格式
        // 对于 qB 或 transmission 用户来说,基本上不太可能遇到这种文件
        if (file.endsWith(".magnet", Qt::CaseInsensitive))
            torrents << fileAbsPath;
        else if (BitTorrent::TorrentInfo::loadFromFile(fileAbsPath).isValid()) // 对于 .torrent 后缀的文件,加载并判断合法性
            torrents << fileAbsPath;
        else if (!m_partialTorrents.contains(fileAbsPath)) // 不是合法的torrent文件,程序认为它是一个局部种子文件,即不完整的,主要原因是考虑到io速度,这个文件可能还没有写完就在读取了
            m_partialTorrents[fileAbsPath] = 0; // 局部文件,添加到 m_partialTorrents 这个 hash表,key 为路径,value为检测次数
    }

    // 找到种子了(不管是新是旧), fire 一个 torrentsAdded 信号, 然后qB另外部分的代码收到这个信号,就会开始新种子下载了
    if (!torrents.empty())
        emit torrentsAdded(torrents);

    // 如果局部种子hash表非空 并且 m_partialTorrentTimer 定时器 是非活跃 not running (pending), 则启动一个定时器
    // 之所以要启动,前面我们已经分析过了,m_partialTorrentTimer 在构造方法里,被设置成了 single-shot timer
    if (!m_partialTorrents.empty() && !m_partialTorrentTimer.isActive())
        m_partialTorrentTimer.start(WATCH_INTERVAL);
}

所以,在不存在“局部种子”的情况下, 本地文件系统下的被 watch 的目录,只会在qB添加这个目录的时候被扫描一次,后续的任务都交给了QFileSystemWatcher::directoryChanged信号。 收到这个信号就会重新扫描一次被 watch 的目录。

所以, qB 并没有做其它特殊的处理,它完全依赖 QFileSystemWatcher 本身的机制 (而 QFileSystemWatcher 又是依赖 inotify )。

然而,事情的真相真的是这样么?

我基于 一个gist 修改了下,做了一个简单的测试demo。

代码仓库在这https://github.com/ttys3/qt_directory_watcher

watcher.pro 文件内容如下:

#-------------------------------------------------
#
# Project created by QtCreator 2020-06-08T16:24:38
#
#-------------------------------------------------

QT       += core widgets

QT       -= gui

TARGET    = watcher

CONFIG   += console
CONFIG   -= app_bundle

TEMPLATE = app


SOURCES += qt_directory_watcher.cpp

qt_directory_watcher.cpp 内容如下:

// ----------------------------------------------
// List the contents of a directory if changed
// Using QFileSystemWatcher, QDirIterator and
// QEventLoop, and lambda function for connect
// ----------------------------------------------

#include <QApplication>
#include <QObject>
#include <QEventLoop>
#include <QDebug>
#include <QFileSystemWatcher>
#include <QDirIterator>

void listDirectoryContents( const QString& dir ) noexcept
{
    QFileSystemWatcher watcher;
    watcher.addPath( dir );

    QEventLoop loop;
    
    QObject::connect( &watcher, &QFileSystemWatcher::directoryChanged,
                      []( const QString& path )
    {
        qDebug() << "\n----------------------------------------------------------";

        QDirIterator it( path,
                        { "*.torrent" },        // Filter: *.torrent
                        QDir::Files );   // Files only

        while ( it.hasNext() )              // List all txt files
        {                                   // on console
            qDebug() << it.next();
        }
    });
    
    // QObject::connect( &watcher, &QFileSystemWatcher::directoryChanged, &loop, &QEventLoop::quit );
    
    loop.exec();
}

// Example

int main(int argc, char** argv)
{
    if (argc != 2) {
        qDebug() << "usage: " << *argv <<  "path";
        return -1;
    }
    QCoreApplication app(argc, argv);
    const QString dir { *(argv+1) };
    listDirectoryContents( dir );
    app.exec();
    return 0;
}

构建:

qmake-qt5
make

测试运行:

./watcher /run/media/ttys3/sdc-media/game/for-watch

这里的/run/media/ttys3/sdc-media/game/for-watch是 NTFS 移动磁盘中的一个目录。 测试的结果表明, QFileSystemWatcher 完全能够监视 NTFS 文件系统中的文件。

于是我重新看了下挂载参数:

❯ mount | grep /run/media/ttys3/sdc-media
/dev/sdd5 on /run/media/ttys3/sdc-media type fuseblk (rw,nosuid,nodev,relatime,user_id=0,group_id=0,default_permissions,allow_other,blksize=4096,uhelper=udisks2)

没错, 这个NTFS分区,是以 fuseblk 文件系统的方式挂载上的。具体的实现是由 ntfs-3g实现的。

下载源码看了下,唯一跟这个相关的调用是src/lowntfs-3g.c中的fuse_lowlevel_notify_inval_inode 调用。

所以,应该是 ntfs-3g 实现的 fuseblk 已经支持 notify 了, 而内核的 inotify 也有相应的支持。

因此, QFileSystemWatcher 完全不用针对 NTFS 做出任何特别的判断,它只需要照单接收即可。

结论

Linux 下以 fuseblk 或 fuse 方式挂载的 NTFS 文件系统 是支持 inotify 的, 因此 qB 能监视 这种方式挂载的 NTFS 目录。 而对于网络文件系统(比如 nfs 和 smb 之类的),qB 需要用定时器每隔一段时间对需要监视的目录进行扫描。

参考

https://www.tuxera.com/community/open-source-ntfs-3g/

https://github.com/libfuse/libfuse/wiki/Fsnotify-and-FUSE

https://blog.rburchell.com/2012/01/qfilesystemwatcher-internals-in-qt-5.html

https://www.kernel.org/doc/html/latest/filesystems/fuse.html

https://www.kernel.org/doc/Documentation/filesystems/fuse.txt

https://www.kernel.org/doc/html/latest/filesystems/inotify.html

https://www.kernel.org/doc/html/latest/filesystems/ntfs.html

https://libfuse.github.io/doxygen/notify__inval__inode_8c.html