#include "MainWindow.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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(this); watchers_.insert(taskIndex, watcher); connect(watcher, &QFutureWatcher::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; }