Files
auCDtect_linux/src/MainWindow.cpp

671 lines
23 KiB
C++

#include "MainWindow.h"
#include <QCloseEvent>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QDirIterator>
#include <QFormLayout>
#include <QFutureWatcher>
#include <QGroupBox>
#include <QHeaderView>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QProcess>
#include <QPushButton>
#include <QSettings>
#include <QSpinBox>
#include <QSplitter>
#include <QStandardPaths>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextStream>
#include <QVBoxLayout>
#include <QWidget>
#include <QtConcurrent/QtConcurrentRun>
namespace {
constexpr auto kOrgName = "aucdtect-linux";
constexpr auto kAppName = "aucdtect-linux";
constexpr auto kDecoderTemplateKey = "decoderCommand";
constexpr auto kReportPathKey = "reportPath";
constexpr auto kParallelismKey = "parallelism";
constexpr auto kStatusPending = "Pending";
constexpr auto kStatusRunning = "Running";
constexpr auto kStatusDone = "Done";
constexpr auto kStatusFailed = "Failed";
constexpr auto kStatusStopped = "Stopped";
constexpr auto kDefaultDecoderTemplate = "ffmpeg -loglevel error -y -i {input} -map 0:a:0 -vn -sn -dn -ar 44100 -ac 2 -c:a pcm_s16le {decoded}";
const QStringList kAudioNameFilters = {
"*.wav", "*.flac", "*.ape", "*.wv", "*.tak", "*.tta", "*.m4a", "*.alac", "*.shn", "*.ofr", "*.ofs", "*.mp3", "*.aac"
};
bool isWavInput(const QString &path) {
return QFileInfo(path).suffix().compare("wav", Qt::CaseInsensitive) == 0;
}
QString computeFileHash(const QString &path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
QCryptographicHash hash(QCryptographicHash::Md5);
while (!file.atEnd()) {
hash.addData(file.read(1 << 20));
}
return hash.result().toHex();
}
QString computeSignature(const QString &inputPath, const QString &conclusion, const QString &accuracy, const QString &fileHash) {
const QString material = inputPath + "|" + conclusion + "|" + accuracy + "|" + fileHash;
return QCryptographicHash::hash(material.toUtf8(), QCryptographicHash::Sha1).toHex();
}
struct WorkerPayload {
int taskIndex = -1;
QString inputPath;
QString decoderTemplate;
QString reportPath;
};
QString escapeShellArgumentStandalone(const QString &value) {
QString escaped = value;
escaped.replace('\'', "'\"'\"'");
return "'" + escaped + "'";
}
QString applyPlaceholders(QString command, const QString &inputPath, const QString &decodedPath, const QString &reportPath) {
command.replace("{input}", escapeShellArgumentStandalone(inputPath));
command.replace("{decoded}", escapeShellArgumentStandalone(decodedPath));
command.replace("{report}", escapeShellArgumentStandalone(reportPath));
return command;
}
WorkerResult runWorker(const WorkerPayload &payload) {
WorkerResult result;
result.status = kStatusFailed;
const QString baseName = QFileInfo(payload.inputPath).baseName();
const QString cacheBase = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
QDir().mkpath(cacheBase);
const QString tempDirPath = cacheBase + "/" + baseName + "-" + QString::number(QDateTime::currentMSecsSinceEpoch()) +
"-" + QString::number(payload.taskIndex);
QDir().mkpath(tempDirPath);
const QString decodedPath = isWavInput(payload.inputPath) ? payload.inputPath : tempDirPath + "/decoded.wav";
QString externalOutput;
if (!isWavInput(payload.inputPath)) {
if (payload.decoderTemplate.trimmed().isEmpty()) {
result.conclusion = "Decoder command is required for non-WAV input";
result.analyzerOutput = result.conclusion;
QDir(tempDirPath).removeRecursively();
return result;
}
QProcess decoder;
decoder.setProcessChannelMode(QProcess::MergedChannels);
const QString reportPath = payload.reportPath.isEmpty() ? QDir(tempDirPath).filePath("analysis-report.txt") : payload.reportPath;
const QString command = applyPlaceholders(payload.decoderTemplate, payload.inputPath, decodedPath, reportPath);
decoder.start("/bin/sh", {"-lc", command});
if (!decoder.waitForStarted()) {
result.conclusion = "Decoder failed to start";
result.analyzerOutput = result.conclusion;
QDir(tempDirPath).removeRecursively();
return result;
}
decoder.waitForFinished(-1);
result.exitCode = decoder.exitCode();
externalOutput = QString::fromLocal8Bit(decoder.readAll());
if (decoder.exitStatus() != QProcess::NormalExit || decoder.exitCode() != 0) {
result.conclusion = "Decoder failed";
result.analyzerOutput = externalOutput;
QDir(tempDirPath).removeRecursively();
return result;
}
}
AudioAnalyzer analyzer;
const AudioAnalysisReport report = analyzer.analyzeFile(decodedPath);
if (!report.ok) {
result.conclusion = report.error;
result.analyzerOutput = report.rawReport.isEmpty() ? report.error : report.rawReport;
QDir(tempDirPath).removeRecursively();
return result;
}
result.status = kStatusDone;
result.exitCode = 0;
result.conclusion = report.conclusion;
result.accuracy = report.accuracy;
result.fileHash = computeFileHash(payload.inputPath);
result.signature = computeSignature(payload.inputPath, report.conclusion, report.accuracy, result.fileHash);
result.analyzerOutput = report.rawReport;
if (!externalOutput.trimmed().isEmpty()) {
result.analyzerOutput += "\n\nDecoder output\n--------------\n" + externalOutput.trimmed() + "\n";
}
QDir(tempDirPath).removeRecursively();
return result;
}
}
MainWindow::MainWindow() {
buildUi();
loadSettings();
connectSignals();
updateSummary();
updateDetailsPanel();
appendLog("Ready. Internal analysis is built in, worker count controls concurrent jobs.");
}
MainWindow::~MainWindow() = default;
void MainWindow::closeEvent(QCloseEvent *event) {
saveSettings();
QMainWindow::closeEvent(event);
}
void MainWindow::buildUi() {
setWindowTitle("auCDtect Linux");
resize(1360, 860);
auto *central = new QWidget(this);
auto *mainLayout = new QVBoxLayout(central);
auto *toolbarLayout = new QHBoxLayout();
addFilesButton_ = new QPushButton("Add Files", this);
addFolderButton_ = new QPushButton("Add Folder", this);
clearButton_ = new QPushButton("Clear", this);
startButton_ = new QPushButton("Start", this);
stopButton_ = new QPushButton("Stop", this);
exportButton_ = new QPushButton("Save Report", this);
toolbarLayout->addWidget(addFilesButton_);
toolbarLayout->addWidget(addFolderButton_);
toolbarLayout->addWidget(clearButton_);
toolbarLayout->addStretch();
toolbarLayout->addWidget(startButton_);
toolbarLayout->addWidget(stopButton_);
toolbarLayout->addWidget(exportButton_);
auto *engineGroup = new QGroupBox("Engine Setup", this);
auto *engineLayout = new QFormLayout(engineGroup);
decoderCommandEdit_ = new QLineEdit(this);
reportPathEdit_ = new QLineEdit(this);
parallelismSpin_ = new QSpinBox(this);
parallelismSpin_->setRange(1, 32);
parallelismSpin_->setValue(1);
decoderCommandEdit_->setPlaceholderText(kDefaultDecoderTemplate);
reportPathEdit_->setPlaceholderText("Optional shared report path");
engineLayout->addRow("Decoder", decoderCommandEdit_);
engineLayout->addRow("Workers", parallelismSpin_);
engineLayout->addRow("Report File", reportPathEdit_);
auto *summaryGroup = new QGroupBox("Task Summary", this);
auto *summaryLayout = new QHBoxLayout(summaryGroup);
pendingLabel_ = new QLabel(this);
runningLabel_ = new QLabel(this);
doneLabel_ = new QLabel(this);
failedLabel_ = new QLabel(this);
summaryLayout->addWidget(pendingLabel_);
summaryLayout->addWidget(runningLabel_);
summaryLayout->addWidget(doneLabel_);
summaryLayout->addWidget(failedLabel_);
summaryLayout->addStretch();
taskTable_ = new QTableWidget(this);
taskTable_->setColumnCount(6);
taskTable_->setHorizontalHeaderLabels({"File", "Status", "Conclusion", "Accuracy", "Hash", "Exit"});
taskTable_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch);
taskTable_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
taskTable_->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
taskTable_->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
taskTable_->horizontalHeader()->setSectionResizeMode(4, QHeaderView::Stretch);
taskTable_->horizontalHeader()->setSectionResizeMode(5, QHeaderView::ResizeToContents);
taskTable_->verticalHeader()->setVisible(false);
taskTable_->setAlternatingRowColors(true);
taskTable_->setSelectionBehavior(QAbstractItemView::SelectRows);
taskTable_->setEditTriggers(QAbstractItemView::NoEditTriggers);
detailsView_ = new QPlainTextEdit(this);
detailsView_->setReadOnly(true);
detailsView_->setPlaceholderText("Select a task to inspect the detailed report.");
auto *detailsGroup = new QGroupBox("Selected Task", this);
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->addWidget(detailsView_);
auto *topSplitter = new QSplitter(Qt::Horizontal, this);
topSplitter->addWidget(taskTable_);
topSplitter->addWidget(detailsGroup);
topSplitter->setStretchFactor(0, 3);
topSplitter->setStretchFactor(1, 2);
logView_ = new QPlainTextEdit(this);
logView_->setReadOnly(true);
logView_->setPlaceholderText("Execution log");
auto *bottomGroup = new QGroupBox("Run Log", this);
auto *bottomLayout = new QVBoxLayout(bottomGroup);
bottomLayout->addWidget(logView_);
auto *mainSplitter = new QSplitter(Qt::Vertical, this);
mainSplitter->addWidget(topSplitter);
mainSplitter->addWidget(bottomGroup);
mainSplitter->setStretchFactor(0, 4);
mainSplitter->setStretchFactor(1, 2);
auto *topMetaLayout = new QHBoxLayout();
topMetaLayout->addWidget(engineGroup, 2);
topMetaLayout->addWidget(summaryGroup, 1);
mainLayout->addLayout(toolbarLayout);
mainLayout->addLayout(topMetaLayout);
mainLayout->addWidget(mainSplitter);
setCentralWidget(central);
stopButton_->setEnabled(false);
}
void MainWindow::loadSettings() {
QSettings settings(kOrgName, kAppName);
decoderCommandEdit_->setText(settings.value(kDecoderTemplateKey, kDefaultDecoderTemplate).toString());
reportPathEdit_->setText(settings.value(kReportPathKey).toString());
parallelismSpin_->setValue(settings.value(kParallelismKey, 1).toInt());
}
void MainWindow::saveSettings() const {
QSettings settings(kOrgName, kAppName);
settings.setValue(kDecoderTemplateKey, decoderCommandEdit_->text().trimmed());
settings.setValue(kReportPathKey, reportPathEdit_->text().trimmed());
settings.setValue(kParallelismKey, parallelismSpin_->value());
}
void MainWindow::connectSignals() {
connect(addFilesButton_, &QPushButton::clicked, this, [this] { addFiles(); });
connect(addFolderButton_, &QPushButton::clicked, this, [this] { addFolder(); });
connect(clearButton_, &QPushButton::clicked, this, [this] { clearTasks(); });
connect(startButton_, &QPushButton::clicked, this, [this] { startQueue(); });
connect(stopButton_, &QPushButton::clicked, this, [this] { stopQueue(); });
connect(exportButton_, &QPushButton::clicked, this, [this] { exportReport(); });
connect(taskTable_, &QTableWidget::itemSelectionChanged, this, [this] { updateDetailsPanel(); });
}
void MainWindow::addFiles() {
const QStringList files = QFileDialog::getOpenFileNames(
this,
"Select audio files",
QString(),
"Audio Files (*.wav *.flac *.ape *.wv *.tak *.tta *.m4a *.alac *.shn *.ofr *.ofs *.mp3 *.aac);;All Files (*)");
addInputFiles(files);
}
void MainWindow::addFolder() {
const QString folder = QFileDialog::getExistingDirectory(this, "Select audio folder");
if (folder.isEmpty()) {
return;
}
QStringList files;
QDirIterator it(folder, kAudioNameFilters, QDir::Files, QDirIterator::Subdirectories);
while (it.hasNext()) {
files.append(it.next());
}
files.sort(Qt::CaseInsensitive);
if (files.isEmpty()) {
QMessageBox::information(this, "No audio files", "No supported audio files were found in this folder.");
return;
}
addInputFiles(files);
}
void MainWindow::addInputFiles(const QStringList &files) {
if (files.isEmpty()) {
return;
}
int added = 0;
for (const QString &file : files) {
const QString normalized = QFileInfo(file).absoluteFilePath();
bool exists = false;
for (const TaskItem &existing : tasks_) {
if (existing.inputPath == normalized) {
exists = true;
break;
}
}
if (exists) {
continue;
}
TaskItem task;
task.inputPath = normalized;
task.status = kStatusPending;
task.conclusion = "-";
tasks_.append(task);
const int row = taskTable_->rowCount();
taskTable_->insertRow(row);
taskTable_->setItem(row, 0, new QTableWidgetItem(normalized));
taskTable_->setItem(row, 1, new QTableWidgetItem(task.status));
taskTable_->setItem(row, 2, new QTableWidgetItem(task.conclusion));
taskTable_->setItem(row, 3, new QTableWidgetItem(task.accuracy));
taskTable_->setItem(row, 4, new QTableWidgetItem("-"));
taskTable_->setItem(row, 5, new QTableWidgetItem("-"));
++added;
}
updateSummary();
appendLog(QString("Added %1 file(s).").arg(added));
}
void MainWindow::clearTasks() {
if (running_) {
QMessageBox::warning(this, "Busy", "Stop the run before clearing the queue.");
return;
}
tasks_.clear();
taskTable_->setRowCount(0);
updateSummary();
updateDetailsPanel();
appendLog("Queue cleared.");
}
void MainWindow::exportReport() {
QString path = reportPathEdit_->text().trimmed();
if (path.isEmpty()) {
path = QFileDialog::getSaveFileName(this, "Export report", "aucdtect-report.txt", "Text Files (*.txt)");
}
if (path.isEmpty()) {
return;
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::critical(this, "Export failed", QString("Cannot open %1").arg(path));
return;
}
QTextStream stream(&file);
stream << composeReport();
file.close();
reportPathEdit_->setText(path);
appendLog(QString("Report saved to %1").arg(path));
}
void MainWindow::startQueue() {
if (tasks_.isEmpty()) {
QMessageBox::information(this, "No tasks", "Add at least one audio file.");
return;
}
if (running_) {
return;
}
stopping_ = false;
running_ = true;
for (TaskItem &task : tasks_) {
if (task.status == kStatusFailed || task.status == kStatusStopped || task.status == kStatusDone) {
task.status = kStatusPending;
task.conclusion = "-";
task.accuracy = "-";
task.fileHash.clear();
task.signature.clear();
task.analyzerOutput.clear();
task.exitCode = -1;
}
}
for (int row = 0; row < tasks_.size(); ++row) {
refreshRow(row);
}
setUiBusy(true);
updateSummary();
appendLog(QString("Queue started with %1 worker(s).").arg(parallelismSpin_->value()));
scheduleWorkers();
}
void MainWindow::stopQueue() {
stopping_ = true;
appendLog("Stop requested. Running workers will finish, pending tasks will be marked as stopped.");
for (TaskItem &task : tasks_) {
if (task.status == kStatusPending) {
task.status = kStatusStopped;
task.conclusion = "Cancelled before start";
}
}
for (int row = 0; row < tasks_.size(); ++row) {
refreshRow(row);
}
updateSummary();
finishRunIfIdle();
}
void MainWindow::scheduleWorkers() {
if (!running_) {
return;
}
while (!stopping_ && watchers_.size() < parallelismSpin_->value()) {
int nextIndex = -1;
for (int i = 0; i < tasks_.size(); ++i) {
if (tasks_[i].status == kStatusPending && !watchers_.contains(i)) {
nextIndex = i;
break;
}
}
if (nextIndex < 0) {
break;
}
startWorker(nextIndex);
}
finishRunIfIdle();
}
void MainWindow::startWorker(int taskIndex) {
TaskItem &task = tasks_[taskIndex];
task.status = kStatusRunning;
task.conclusion = "Analyzing";
task.accuracy = "-";
refreshRow(taskIndex);
updateSummary();
WorkerPayload payload;
payload.taskIndex = taskIndex;
payload.inputPath = task.inputPath;
payload.decoderTemplate = decoderCommandEdit_->text().trimmed();
payload.reportPath = reportPathEdit_->text().trimmed();
appendLog(QString("Worker started for %1").arg(task.inputPath));
auto *watcher = new QFutureWatcher<WorkerResult>(this);
watchers_.insert(taskIndex, watcher);
connect(watcher, &QFutureWatcher<WorkerResult>::finished, this, [this, taskIndex, watcher] {
const WorkerResult result = watcher->result();
watchers_.remove(taskIndex);
watcher->deleteLater();
applyWorkerResult(taskIndex, result);
scheduleWorkers();
});
watcher->setFuture(QtConcurrent::run(runWorker, payload));
}
void MainWindow::applyWorkerResult(int taskIndex, const WorkerResult &result) {
if (taskIndex < 0 || taskIndex >= tasks_.size()) {
return;
}
TaskItem &task = tasks_[taskIndex];
task.status = result.status;
task.conclusion = result.conclusion;
task.accuracy = result.accuracy.isEmpty() ? "-" : result.accuracy;
task.fileHash = result.fileHash;
task.signature = result.signature;
task.analyzerOutput = result.analyzerOutput;
task.exitCode = result.exitCode;
refreshRow(taskIndex);
updateSummary();
updateDetailsPanel();
appendLog(QString("[%1] %2").arg(task.status, task.inputPath));
}
void MainWindow::finishRunIfIdle() {
if (!running_) {
return;
}
if (!watchers_.isEmpty()) {
return;
}
bool hasPending = false;
bool hasRunning = false;
for (const TaskItem &task : tasks_) {
hasPending |= (task.status == kStatusPending);
hasRunning |= (task.status == kStatusRunning);
}
if (hasPending || hasRunning) {
return;
}
running_ = false;
setUiBusy(false);
appendLog(stopping_ ? "Queue stopped." : "Queue finished.");
}
void MainWindow::appendLog(const QString &message) {
const QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss");
logView_->appendPlainText(QString("[%1] %2").arg(timestamp, message));
}
void MainWindow::setUiBusy(bool busy) {
addFilesButton_->setEnabled(!busy);
addFolderButton_->setEnabled(!busy);
clearButton_->setEnabled(!busy);
startButton_->setEnabled(!busy);
stopButton_->setEnabled(busy);
}
void MainWindow::refreshRow(int row) {
if (row < 0 || row >= tasks_.size()) {
return;
}
const TaskItem &task = tasks_[row];
taskTable_->item(row, 1)->setText(task.status);
taskTable_->item(row, 2)->setText(task.conclusion);
taskTable_->item(row, 3)->setText(task.accuracy);
taskTable_->item(row, 4)->setText(task.fileHash.isEmpty() ? "-" : task.fileHash);
taskTable_->item(row, 5)->setText(task.exitCode >= 0 ? QString::number(task.exitCode) : "-");
}
void MainWindow::updateSummary() {
int pending = 0;
int running = 0;
int done = 0;
int failed = 0;
for (const TaskItem &task : tasks_) {
if (task.status == kStatusPending) {
++pending;
} else if (task.status == kStatusRunning) {
++running;
} else if (task.status == kStatusDone) {
++done;
} else if (task.status == kStatusFailed || task.status == kStatusStopped) {
++failed;
}
}
pendingLabel_->setText(QString("Pending: %1").arg(pending));
runningLabel_->setText(QString("Running: %1").arg(running));
doneLabel_->setText(QString("Done: %1").arg(done));
failedLabel_->setText(QString("Failed: %1").arg(failed));
}
void MainWindow::updateDetailsPanel() {
const auto selected = taskTable_->selectionModel() ? taskTable_->selectionModel()->selectedRows() : QModelIndexList{};
if (selected.isEmpty()) {
detailsView_->clear();
return;
}
const int row = selected.first().row();
if (row < 0 || row >= tasks_.size()) {
detailsView_->clear();
return;
}
const TaskItem &task = tasks_[row];
QString details;
QTextStream stream(&details);
stream << "File : " << task.inputPath << "\n";
stream << "Status : " << task.status << "\n";
stream << "Conclusion : " << task.conclusion << "\n";
stream << "Accuracy : " << task.accuracy << "\n";
stream << "Hash : " << (task.fileHash.isEmpty() ? "-" : task.fileHash) << "\n";
stream << "Signature : " << (task.signature.isEmpty() ? "-" : task.signature) << "\n";
stream << "Exit code : " << task.exitCode << "\n\n";
stream << (task.analyzerOutput.isEmpty() ? "No detailed report yet." : task.analyzerOutput.trimmed());
detailsView_->setPlainText(details);
}
QString MainWindow::substitutePlaceholders(const QString &templateText, const TaskItem &task) const {
QString result = templateText;
result.replace("{input}", escapeShellArgument(task.inputPath));
result.replace("{decoded}", escapeShellArgument(task.decodedPath));
QString reportPath = reportPathEdit_->text().trimmed();
if (reportPath.isEmpty()) {
reportPath = QDir(task.tempDirPath).filePath("analysis-report.txt");
}
result.replace("{report}", escapeShellArgument(reportPath));
return result;
}
QString MainWindow::escapeShellArgument(const QString &value) const {
QString escaped = value;
escaped.replace('\'', "'\"'\"'");
return "'" + escaped + "'";
}
QString MainWindow::composeReport() const {
QString report;
QTextStream stream(&report);
stream << "auCDtect Linux Report\n";
stream << "Date: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n";
stream << "Workers: " << parallelismSpin_->value() << "\n\n";
for (const TaskItem &task : tasks_) {
stream << "FILE: " << task.inputPath << "\n";
stream << "Accuracy : " << task.accuracy << "\n";
stream << "Conclusion : " << task.conclusion << "\n";
stream << "Status : " << task.status << "\n";
stream << "Hash : " << (task.fileHash.isEmpty() ? "-" : task.fileHash) << "\n";
stream << "Signature : " << (task.signature.isEmpty() ? "-" : task.signature) << "\n";
stream << "Exit code : " << task.exitCode << "\n";
if (!task.analyzerOutput.isEmpty()) {
stream << "\n" << task.analyzerOutput.trimmed() << "\n";
}
stream << "\n";
}
return report;
}