Files
op-pedal/src/AudioFilesTest.cpp
T
2025-05-31 21:06:34 -04:00

537 lines
18 KiB
C++

// Copyright (c) 2025 Robin Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#include "pch.h"
#include "catch.hpp"
#include <string>
#include <iostream>
#include "AudioFiles.hpp"
#include <filesystem>
#include "SysExec.hpp"
#include "json.hpp"
#include "json_variant.hpp"
#include "MimeTypes.hpp"
using namespace std;
using namespace pipedal;
namespace fs = std::filesystem;
fs::path testDir = fs::temp_directory_path() / "AudioFilesTest" / "files";
fs::path sourceDirectory = fs::current_path() / "artifacts" / "TestTracks";
fs::path tempDir = fs::temp_directory_path() / "AudioFilesTest" / "tmp";
fs::path resourceDir = fs::current_path() / "vite" / "public" / "img";
fs::path textIndexPath = fs::temp_directory_path() / "AudioFilesTest" / "TextIndex.pipedal";
static void CreateTestDirectory()
{
if (fs::exists(testDir))
{
fs::remove_all(testDir);
}
fs::remove_all(tempDir);
fs::create_directories(testDir);
if (fs::exists(tempDir))
{
fs::remove_all(tempDir);
}
fs::create_directories(tempDir);
AudioDirectoryInfo::SetTemporaryDirectory(tempDir);
AudioDirectoryInfo::SetResourceDirectory(resourceDir);
}
static void CheckForLeakedTemporaryFiles()
{
bool leakedFiles = false;
// Check if the temporary directory exists and is not empty
if (fs::exists(tempDir) && fs::is_directory(tempDir))
{
for (const auto &entry : fs::directory_iterator(tempDir))
{
if (entry.is_regular_file())
{
cout << "Leaked temporary file: " << entry.path() << endl;
leakedFiles = true;
}
}
}
if (fs::exists(tempDir) && fs::is_directory(tempDir / "thumbnails"))
{
for (const auto &entry : fs::directory_iterator(tempDir / "thumbnails"))
{
if (entry.is_regular_file())
{
cout << "Leaked temporary file: " << entry.path() << endl;
leakedFiles = true;
}
}
}
if (leakedFiles)
{
throw std::runtime_error("Leaked temporary files found in: " + tempDir.string());
}
}
static bool ContainsTrack(const std::vector<AudioFileMetadata> &files, const std::string &trackName)
{
for (const auto &file : files)
{
if (file.title() == trackName)
{
return true;
}
}
return false;
}
static bool ContainsFile(const std::vector<AudioFileMetadata> &files, const std::string &fileName)
{
for (const auto &file : files)
{
if (file.fileName() == fileName)
{
return true;
}
}
return false;
}
static const AudioFileMetadata *GetFile(const std::vector<AudioFileMetadata> &files, const std::string &fileName)
{
for (const auto &file : files)
{
if (file.fileName() == fileName)
{
return &file;
}
}
throw std::logic_error("File not found: " + fileName);
}
static void PrintFiles(const std::vector<AudioFileMetadata> &files)
{
for (const auto &file : files)
{
cout << "File: " << file.fileName() << ", Title: " << file.track() << ". " << file.title() << " Album: " << file.album() << endl;
}
cout << endl;
}
void IndexTest()
{
CreateTestDirectory();
try
{
{
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
}
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
REQUIRE(audioDir != nullptr);
if (fs::exists(testDir))
{
fs::remove_all(testDir); // Remove any existing database file
}
fs::create_directories(testDir);
// Copy test files to the test directory
for (const auto &entry : fs::directory_iterator(sourceDirectory))
{
if (entry.is_regular_file())
{
fs::copy(entry.path(), testDir / entry.path().filename());
}
}
auto files = audioDir->GetFiles();
PrintFiles(files);
REQUIRE(files.size() == 3);
REQUIRE(ContainsTrack(files, "Track 1"));
REQUIRE(ContainsTrack(files, "Track 2"));
REQUIRE(ContainsTrack(files, "Track 3"));
for (const auto &file : files)
{
REQUIRE(!file.fileName().empty());
}
// check indexed results.
files = audioDir->GetFiles();
REQUIRE(files.size() == 3);
for (const auto &file : files)
{
REQUIRE(!file.fileName().empty());
}
fs::remove(testDir / "Track2.mp3");
files = audioDir->GetFiles();
REQUIRE(files.size() == 2);
REQUIRE(!ContainsTrack(files, "Track 2"));
REQUIRE(ContainsTrack(files, "Track 1"));
REQUIRE(ContainsTrack(files, "Track 3"));
PrintFiles(files);
files = audioDir->GetFiles();
REQUIRE(files.size() == 2);
REQUIRE(!ContainsTrack(files, "Track 2"));
REQUIRE(ContainsTrack(files, "Track 1"));
REQUIRE(ContainsTrack(files, "Track 3"));
// check dbinex results.
fs::copy_file(sourceDirectory / "Track2.mp3", testDir / "Track3.mp3", fs::copy_options::overwrite_existing);
files = audioDir->GetFiles();
REQUIRE(files.size() == 2);
REQUIRE(ContainsTrack(files, "Track 2"));
REQUIRE(ContainsFile(files, "Track3.mp3"));
REQUIRE(!ContainsFile(files, "Track2.mp3"));
fs::copy_file(sourceDirectory / "Track3.mp3", testDir / "Track4.mp3", fs::copy_options::overwrite_existing);
files = audioDir->GetFiles();
REQUIRE(files.size() == 3);
REQUIRE(ContainsTrack(files, "Track 2"));
REQUIRE(ContainsFile(files, "Track3.mp3"));
REQUIRE(GetFile(files, "Track4.mp3")->title() == "Track 3");
{
auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1);
}
{
auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1);
}
{
auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 0, 0);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2);
}
CheckForLeakedTemporaryFiles();
}
catch (const std::exception &e)
{
cout << "Exception: " << e.what() << endl;
REQUIRE(false);
}
}
static void ThumbnailTest()
{
cout << "ThumbnailTest" << endl;
CreateTestDirectory();
try
{
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
REQUIRE(audioDir != nullptr);
if (fs::exists(testDir))
{
fs::remove_all(testDir); // Remove any existing database file
}
fs::create_directories(testDir);
// Copy test files to the test directory
for (const auto &entry : fs::directory_iterator(sourceDirectory))
{
if (entry.is_regular_file())
{
fs::copy(entry.path(), testDir / entry.path().filename());
}
}
auto files = audioDir->GetFiles();
REQUIRE(files.size() == 3);
for (const auto &file : files)
{
ThumbnailTemporaryFile thumbnail = audioDir->GetThumbnail(file.fileName(), 200, 200);
REQUIRE(fs::exists(thumbnail.Path()));
cout << "Thumbnail for " << file.fileName() << ": " << thumbnail.Path() << endl;
cout << " " << file.fileName() << "(200,200) size: " << fs::file_size(thumbnail.Path()) << endl;
thumbnail = audioDir->GetThumbnail(file.fileName(), 0, 0);
REQUIRE(fs::exists(thumbnail.Path()));
cout << "Thumbnail for " << file.fileName() << ": " << thumbnail.Path() << endl;
cout << " " << file.fileName() << "(0,0) size: " << fs::file_size(thumbnail.Path()) << endl;
}
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2 * 2); // 3 files, one file is missing a thumbnail, 2 per file.
{
ThumbnailTemporaryFile thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2 * 2); // 3 files, one file is missing a thumbnail, 2 per file.
}
fs::remove(testDir / "Track2.mp3");
files = audioDir->GetFiles();
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2);
// update the last write time, should trigger a thumbnail update.
REQUIRE(GetFile(files, "Track1.mp3")->thumbnailType() == ThumbnailType::Embedded);
fs::copy(sourceDirectory / "Track1.mp3", testDir / "Track1.mp3", fs::copy_options::overwrite_existing);
files = audioDir->GetFiles();
REQUIRE(GetFile(files, "Track1.mp3")->thumbnailType() == ThumbnailType::Unknown);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 0);
audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1);
CheckForLeakedTemporaryFiles();
cout << endl;
}
catch (const std::exception &e)
{
cout << "Exception: " << e.what() << endl;
REQUIRE(false);
}
}
void ExecutionTimeTest()
{
cout << "Timing test" << endl;
CreateTestDirectory();
// deploy sample files.
fs::create_directories(testDir);
// Copy test files to the test directory
for (const auto &entry : fs::directory_iterator(sourceDirectory))
{
if (entry.is_regular_file())
{
fs::copy(entry.path(), testDir / entry.path().filename());
}
}
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
audioDir->GetFiles();
{
using clock_t = std::chrono::high_resolution_clock;
{
auto start = clock_t::now();
audioDir->GetThumbnail("Track1.mp3",200,200);
auto duration = clock_t::now() - start;
cout << "Track1.mp3(200,200): " << std::chrono::duration_cast<std::chrono::milliseconds>(duration).count() <<"ms" << endl;
}
{
auto start = clock_t::now();
audioDir->GetThumbnail("Track1.mp3",200,200);
auto duration = clock_t::now() - start;
cout << "Track1.mp3(200,200): " << std::chrono::duration_cast<std::chrono::milliseconds>(duration).count() <<"ms" << endl;
}
}
}
TEST_CASE("AudioFiles test", "[AudioFiles]")
{
IndexTest();
ThumbnailTest();
ExecutionTimeTest();
}
// from AudioFileMetadata.cpp
std::string shell_escape_filename(const std::string &filename);
static void ScanThumbnails()
{
const std::string homeDir = getenv("HOME");
std::filesystem::path musicDir = std::filesystem::path(homeDir) / "Music";
for (const auto &entry : std::filesystem::recursive_directory_iterator(musicDir))
{
if (entry.is_directory())
{
continue; // Skip directories
}
if (entry.path().extension() == ".mp3" || entry.path().extension() == ".flac")
{
try
{
// ffprobe -i ~/tmp/test.flac -show_streams -show_entries stream=index,codec_name,disposition
std::stringstream args;
args << "-log_level error -i " << shell_escape_filename(entry.path())
<< " -show_streams -of json -show_entries stream=index,codec_name,disposition 2>/dev/null";
std::string sargs = args.str();
auto output = sysExecForOutput("/usr/bin/ffprobe", sargs, true);
if (output.exitCode != 0)
{
cout << "ffprobe failed for file: " << entry.path() << endl;
continue;
}
std::stringstream ss(output.output);
json_reader reader(ss);
json_variant vt;
reader.read(&vt);
auto streams = vt.as_object()->at("streams").as_array();
struct StreamInfo
{
int index;
std::string codec_name;
std::string comment;
};
std::vector<StreamInfo> thumbnailStreams;
for (const auto &stream : *streams)
{
auto streamObj = stream.as_object();
int index = streamObj->at("index").as_int64();
std::string codecName = streamObj->at("codec_name").as_string();
std::string comment;
if (streamObj->contains("tags"))
{
auto &tagsObj = streamObj->at("tags");
auto &tags = tagsObj.as_object();
if (tags->contains("comment"))
{
comment = tags->at("comment").as_string();
}
}
thumbnailStreams.push_back({index, codecName, comment});
}
if (thumbnailStreams.size() > 2)
{
cout << "File: " << entry.path() << endl;
for (const auto &stream : thumbnailStreams)
{
cout << " Stream Index: " << stream.index
<< ", Codec: " << stream.codec_name
<< ", Comment: " << stream.comment << endl;
}
}
}
catch (const std::exception &e)
{
cout << "Error processing file: " << entry.path() << " - " << e.what() << endl;
}
}
}
}
class TestTimer {
public:
using clock_t = std::chrono::high_resolution_clock;
void Start()
{
startTime = clock_t::now();
}
uint64_t Stop()
{
auto elapsed = clock_t::now()-startTime;
elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
return elapsedMs;
}
double ElapsedMs()
{
return elapsedMs;
}
private:
clock_t::time_point startTime;
uint64_t elapsedMs = 0;
};
static bool isAudioExtension(const std::string &extension)
{
return MimeTypes::instance().AudioExtensions().contains(extension);
}
static bool ContainsMusic(const std::filesystem::path&path)
{
for (auto dirEntry: fs::directory_iterator(path)) {
if (fs::is_regular_file(dirEntry.path())) {
if (isAudioExtension(dirEntry.path().extension())) {
return true;
}
}
}
return false;
}
void ProfileMusicDirectory() {
const std::string homeDir = getenv("HOME");
std::filesystem::path musicDir = std::filesystem::path(homeDir) / "Music";
for (const auto &entry : std::filesystem::recursive_directory_iterator(musicDir))
{
if (!entry.is_directory())
{
continue; // Skip directories
}
if (!ContainsMusic(entry.path())) {
continue;
}
fs::remove(textIndexPath);
AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path());
fs::create_directories(textIndexPath.parent_path());
directoryInfo->TestSetIndexPath(textIndexPath);
uint64_t firstTime;
uint64_t dbIndexSize;
uint64_t entries;
{
TestTimer t;
t.Start();
AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path());
directoryInfo->TestSetIndexPath(textIndexPath);
entries = directoryInfo->GetFiles().size();
firstTime = t.Stop();
dbIndexSize = fs::file_size(textIndexPath);
}
uint64_t secondTime;
{
TestTimer t;
t.Start();
AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path());
directoryInfo->TestSetIndexPath(textIndexPath);
directoryInfo->GetFiles();
secondTime = t.Stop();
}
cout << entry.path().string() << endl;
cout << " " << "first time: " << firstTime << " second time: " << secondTime << endl;
cout << " " << "size: " << dbIndexSize << " entries: " << entries << endl;
}
}
TEST_CASE("Search for audio files with multple thumbnails", "[ScanThumbnails]")
{
AudioDirectoryInfo::SetTemporaryDirectory(tempDir);
AudioDirectoryInfo::SetResourceDirectory(resourceDir);
ScanThumbnails(); // scan all files in music directory.
ProfileMusicDirectory(); // Scans all folders in music directory.
REQUIRE(true); // Just to ensure the test runs without failure
}
TEST_CASE("Thumbnail performance test", "[ProfileThumbnails]" )
{
AudioDirectoryInfo::SetTemporaryDirectory(tempDir);
AudioDirectoryInfo::SetResourceDirectory(resourceDir);
ProfileMusicDirectory(); // Scans all folders in music directory.
REQUIRE(true); // Just to ensure the test runs without failure
}