diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json index 6c60969..a709077 100644 --- a/.vscode/c_cpp_properties.json +++ b/.vscode/c_cpp_properties.json @@ -7,6 +7,7 @@ "${workspaceFolder}", "${workspaceFolder}/build/src/**", "${workspaceFolder}/src/**", + "${workspaceFolder}/modules/websocketpp/**", "${workspaceFolder}/**", "/usr/include/lilv-0", "/usr/include/x86_64-linux-gnu", diff --git a/.vscode/launch.json b/.vscode/launch.json index 3dd766a..2ab0750 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,6 +22,7 @@ // Resolved by CMake Tools: "program": "${command:cmake.launchTargetPath}", "args": [ + "[ProfileThumbnails]" ], "stopAtEntry": false, "cwd": "${workspaceFolder}", diff --git a/CMakeLists.txt b/CMakeLists.txt index ff425b6..eb19d8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,7 +16,7 @@ enable_testing() #add_subdirectory("submodules/pipedal_p2pd") - +add_subdirectory("modules/SQLiteCpp") add_subdirectory("PiPedalCommon") add_subdirectory("vite") diff --git a/PiPedalCommon/src/CMakeLists.txt b/PiPedalCommon/src/CMakeLists.txt index 9ab01b6..6d5776f 100644 --- a/PiPedalCommon/src/CMakeLists.txt +++ b/PiPedalCommon/src/CMakeLists.txt @@ -13,8 +13,8 @@ set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror") if (CMAKE_BUILD_TYPE MATCHES Debug) message(STATUS "Debug build") - - set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -DDEBUG -D_GLIBCXX_DEBUG" ) + # Must not use -D_GLIBCXX_DEBUG (incompatible with SQLiteCpp) + set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -DDEBUG " ) endif() # Can't get the pkg_check to work. diff --git a/PiPedalCommon/src/SysExec.cpp b/PiPedalCommon/src/SysExec.cpp index 2244cf3..3ffd657 100644 --- a/PiPedalCommon/src/SysExec.cpp +++ b/PiPedalCommon/src/SysExec.cpp @@ -268,7 +268,7 @@ int pipedal::sysExecWait(ProcessId pid_) } -SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::string &args) +SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::string &args, bool discardStderr) { namespace fs = std::filesystem; fs::path fullPath = findOnSystemPath(program); @@ -277,7 +277,13 @@ SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::s throw std::runtime_error(SS("Path does not exist. " << fullPath)); } std::stringstream s; - s << fullPath.c_str() << " " << args << " 2>&1"; + s << fullPath.c_str() << " " << args; + if (discardStderr) + { + s << " 2>/dev/null"; + } else { + s << " 2>&1"; // redirect stderr to stdout + } std::string fullCommand = s.str(); FILE *output = popen(fullCommand.c_str(), "r"); diff --git a/PiPedalCommon/src/include/SysExec.hpp b/PiPedalCommon/src/include/SysExec.hpp index 7b0a1d2..8bfc661 100644 --- a/PiPedalCommon/src/include/SysExec.hpp +++ b/PiPedalCommon/src/include/SysExec.hpp @@ -34,7 +34,7 @@ namespace pipedal std::string output; }; - SysExecOutput sysExecForOutput(const std::string& command, const std::string&args); + SysExecOutput sysExecForOutput(const std::string& command, const std::string&args, bool discardStderr = false); using ProcessId = int64_t; // platform-agnostic wrapper for pid_t; // Returns a pid or -1 on errror. diff --git a/artifacts/TestTracks/Track1.mp3 b/artifacts/TestTracks/Track1.mp3 new file mode 100644 index 0000000..a319312 Binary files /dev/null and b/artifacts/TestTracks/Track1.mp3 differ diff --git a/artifacts/TestTracks/Track2.mp3 b/artifacts/TestTracks/Track2.mp3 new file mode 100644 index 0000000..b5750cc Binary files /dev/null and b/artifacts/TestTracks/Track2.mp3 differ diff --git a/artifacts/TestTracks/Track3.mp3 b/artifacts/TestTracks/Track3.mp3 new file mode 100644 index 0000000..8399af7 Binary files /dev/null and b/artifacts/TestTracks/Track3.mp3 differ diff --git a/artifacts/missing_thumbnail.svgz b/artifacts/missing_thumbnail.svgz new file mode 100644 index 0000000..ae3a106 Binary files /dev/null and b/artifacts/missing_thumbnail.svgz differ diff --git a/src/AudioFileMetadata.cpp b/src/AudioFileMetadata.cpp index 5bbb4b8..d6a6fa6 100644 --- a/src/AudioFileMetadata.cpp +++ b/src/AudioFileMetadata.cpp @@ -22,220 +22,21 @@ */ #include "AudioFileMetadata.hpp" -#include -#include -#include -#include -#include "LRUCache.hpp" -#include using namespace pipedal; -namespace fs = std::filesystem; -namespace chrono = std::chrono; - - -// Safely encode a filename for use in a shell command -static std::string shell_escape_filename(const std::string& filename) { - std::string escaped; - escaped.reserve(filename.size() + 2); // Reserve space for quotes and content - - // Wrap the filename in single quotes - escaped += '\''; - - for (char c : filename) { - // Escape single quotes by closing the quote, adding escaped quote, and reopening - if (c == '\'') { - escaped += "'\\''"; - } else { - escaped += c; - } - } - - escaped += '\''; - return escaped; -} - - -static std::string readPipeToString(FILE* file) { - std::string content; - char buffer[4096]; - size_t bytesRead; - while ((bytesRead = fread(buffer, 1, sizeof(buffer), file)) > 0) { - content.append(buffer, bytesRead); - } - return content; -} - - -static std::string GetJsonMetadata(const std::filesystem::path &path) -{ - /* ffprobe -loglevel error -show_streams -show_format -print_format stream_tags -of json - ~/filename.flac */ - - std::stringstream ss; - ss << "/usr/bin/ffprobe -loglevel error -show_streams -show_format -print_format stream_tags -of json " - << shell_escape_filename(path.string()) - << " 2>/dev/null"; - - FILE *output = popen(ss.str().c_str(),"r"); - if(output == nullptr) - { - throw std::runtime_error("Failed to process file."); - } - std::string result; - try { - result = readPipeToString(output); - } catch (const std::exception&e) - { - pclose(output); - throw std::runtime_error(e.what()); - } - int retcode = pclose(output); - if (retcode != 0) - { - throw std::runtime_error(result); - } - return result; -} - -static float MetadataFloat(const json_variant&vt, float defaultValue = 0) -{ - if (vt.is_string()) - { - std::string s = vt.as_string(); - std::stringstream ss(s); - float result = defaultValue; - ss >> result; - return result; - } - return defaultValue; -} - -static std::string MetadataString(const json_variant&o,const std::vector& names) { - for (const auto&name: names) - { - auto result = (*o.as_object())[name]; - if (result.is_string()) - { - return result.as_string(); - } - } - return ""; -} - -AudioFileMetadata::AudioFileMetadata(const std::filesystem::path &file) -{ - try { - const std::string json = GetJsonMetadata(file); - - json_variant vt; - - std::stringstream ss(json); - json_reader reader(ss); - - reader.read(&vt); - - auto top = vt.as_object(); - auto format = top->at("format").as_object(); - - this->duration_ = MetadataFloat(format->at("duration"),0); - - auto tags = (*format)["tags"]; - if (tags.is_object()) - { - this->album_ = MetadataString(tags,{"ALBUM","album"}); - this->artist_ = MetadataString(tags,{"ARTIST","artist"}); - this->albumArtist_ = MetadataString(tags,{"ALBUM ARTIST","album_artist","album artist"}); - this->title_ = MetadataString(tags,{"TITLE","title"}); - this->date_ = MetadataString(tags,{"DATE","date"}); - this->year_ = MetadataString(tags,{"YEAR","year"}); - this->track_ = MetadataString(tags,{"track","TRACK",}); - this->disc_ = MetadataString(tags,{"disc","DISC"}); - - if (title_ == "") { - this->title_ = file.stem(); - } - this->totalTracks_ = MetadataString(tags,{"TOTALTRACKS"}); - } - } catch (const std::exception &e) - { - std::string title = file.stem(); - this->title_ = title; - } -} - -namespace { - class AudioCacheKey { - public: - AudioCacheKey(const std::filesystem::path &path) { - this->path = path.string(); - this->lastWrite = std::filesystem::last_write_time(path); - - } - // Equality operator - bool operator==(const AudioCacheKey& other) const { - return path == other.path && lastWrite == other.lastWrite; - } - - public: - std::string path; - fs::file_time_type lastWrite; - }; -} - -namespace std { - template <> - struct hash { - - - size_t operator()(const AudioCacheKey& key) const { - size_t path_hash = hash{}(key.path); - size_t time_hash = hash{}( - duration_cast(key.lastWrite.time_since_epoch()).count() - ); - // Combine hashes (boost::hash_combine approach) - return path_hash ^ (time_hash + 0x9e3779b9 + (path_hash << 6) + (path_hash >> 2)); - } - - - }; -} - - -static LRUCache metadataCache {500}; -static std::mutex metadataCacheMutex; - -std::string pipedal::GetAudioFileMetadataString(const std::filesystem::path &path) -{ - std::lock_guard lock { metadataCacheMutex}; - AudioCacheKey key { path}; - std::string result; - if (metadataCache.get(key,result)) - { - return result; - } - auto metadata = AudioFileMetadata(path); - - std::stringstream ss; - json_writer writer(ss); - writer.write(metadata); - result = ss.str(); - - metadataCache.put(key,result); - return result; -} - JSON_MAP_BEGIN(AudioFileMetadata) JSON_MAP_REFERENCE(AudioFileMetadata, duration) +JSON_MAP_REFERENCE(AudioFileMetadata, fileName) +JSON_MAP_REFERENCE(AudioFileMetadata, lastModified) + JSON_MAP_REFERENCE(AudioFileMetadata, title) JSON_MAP_REFERENCE(AudioFileMetadata, track) JSON_MAP_REFERENCE(AudioFileMetadata, album) -JSON_MAP_REFERENCE(AudioFileMetadata, disc) -JSON_MAP_REFERENCE(AudioFileMetadata, artist) -JSON_MAP_REFERENCE(AudioFileMetadata, albumArtist) -JSON_MAP_REFERENCE(AudioFileMetadata, totalTracks) -JSON_MAP_REFERENCE(AudioFileMetadata, date) -JSON_MAP_REFERENCE(AudioFileMetadata, year) + +JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailType) +JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailFile) +JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailLastModified) +JSON_MAP_REFERENCE(AudioFileMetadata, position) JSON_MAP_END() diff --git a/src/AudioFileMetadata.hpp b/src/AudioFileMetadata.hpp index f755c75..e5c04f2 100644 --- a/src/AudioFileMetadata.hpp +++ b/src/AudioFileMetadata.hpp @@ -8,10 +8,10 @@ * 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 @@ -21,16 +21,16 @@ * SOFTWARE. */ -#pragma once +#pragma once -#include #include +#include +#include +#include #include "json.hpp" -namespace pipedal -{ - +namespace pipedal { #define GETTER_SETTER_REF(name) \ const decltype(name##_) &name() const { return name##_; } \ void name(const decltype(name##_) &value) { name##_ = value; } @@ -46,41 +46,56 @@ namespace pipedal decltype(name##_) name() const { return name##_; } \ void name(decltype(name##_) value) { name##_ = value; } + + enum ThumbnailType + { + Unknown = 0, + Embedded = 1, // embedded in the file + Folder = 2, // from a albumArt.jpg or similar + None = 3 // Use default thumbnail. + }; + class AudioFileMetadata { private: - float duration_ = 0; + std::string fileName_; + int64_t lastModified_ = 0; std::string title_; - std::string track_; + int32_t track_ = -1; // track number, 0-based, -1 if not set std::string album_; - std::string disc_; - std::string artist_; - std::string albumArtist_; - std::string totalTracks_; - std::string date_; - std::string year_; + float duration_ = 0; + int32_t thumbnailType_ = 0; // 0 = unknown, 1 = embedded, 3 = folder, 4 = none + std::string thumbnailFile_; // only when thumbnailType is 3= folder. + int64_t thumbnailLastModified_ = 0; + int32_t position_ = -1; public: + + AudioFileMetadata() = default; AudioFileMetadata(const std::filesystem::path &file); - GETTER_SETTER(duration); - GETTER_SETTER_REF(title); - GETTER_SETTER_REF(track); - GETTER_SETTER_REF(album); - GETTER_SETTER_REF(disc); - GETTER_SETTER_REF(artist); - GETTER_SETTER_REF(albumArtist); + GETTER_SETTER(fileName) + GETTER_SETTER(lastModified) + GETTER_SETTER_REF(title) + GETTER_SETTER_REF(track) + GETTER_SETTER_REF(album) + GETTER_SETTER(duration) + ThumbnailType thumbnailType() const { return (ThumbnailType)thumbnailType_;} + void thumbnailType(ThumbnailType value) { thumbnailType_ = (int32_t)value; } + GETTER_SETTER(thumbnailFile) + GETTER_SETTER(thumbnailLastModified) + GETTER_SETTER(position) + public: DECLARE_JSON_MAP(AudioFileMetadata); }; - std::string GetAudioFileMetadataString(const std::filesystem::path &path); - -#undef GETTER_SETTER -#undef GETTER_SETTER_VEC -#undef GETTER_SETTER_REF + #undef GETTER_SETTER + #undef GETTER_SETTER_VEC + #undef GETTER_SETTER_REF -} + +} \ No newline at end of file diff --git a/src/AudioFileMetadataReader.cpp b/src/AudioFileMetadataReader.cpp new file mode 100644 index 0000000..ea87e9d --- /dev/null +++ b/src/AudioFileMetadataReader.cpp @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AudioFileMetadataReader.hpp" +#include +#include +#include +#include +#include "LRUCache.hpp" +#include +#include +#include "TemporaryFile.hpp" + +using namespace pipedal; +namespace fs = std::filesystem; +namespace chrono = std::chrono; + +// Safely encode a filename for use in a shell command +std::string shell_escape_filename(const std::string &filename) +{ + std::string escaped; + escaped.reserve(filename.size() + 2); // Reserve space for quotes and content + + // Wrap the filename in single quotes + escaped += '\''; + + for (char c : filename) + { + // Escape single quotes by closing the quote, adding escaped quote, and reopening + if (c == '\'') + { + escaped += "'\\''"; + } + else + { + escaped += c; + } + } + + escaped += '\''; + return escaped; +} + +static std::string readPipeToString(FILE *file) +{ + std::string content; + char buffer[4096]; + size_t bytesRead; + while ((bytesRead = fread(buffer, 1, sizeof(buffer), file)) > 0) + { + content.append(buffer, bytesRead); + } + return content; +} + +static std::string GetJsonMetadata(const std::filesystem::path &path) +{ + /* ffprobe -loglevel error -show_streams -show_format -print_format stream_tags -of json + ~/filename.flac */ + + std::stringstream ss; + ss << "/usr/bin/ffprobe -loglevel error -show_streams -show_format -print_format stream_tags -of json " + << shell_escape_filename(path.string()) + << " 2>/dev/null"; + + std::string command = ss.str(); + FILE *output = popen(command.c_str(), "r"); + if (output == nullptr) + { + throw std::runtime_error("Failed to process file."); + } + std::string result; + try + { + result = readPipeToString(output); + } + catch (const std::exception &e) + { + pclose(output); + throw std::runtime_error(e.what()); + } + int retcode = pclose(output); + if (retcode != 0) + { + throw std::runtime_error(result); + } + return result; +} + +static float MetadataFloat(const json_variant &vt, float defaultValue = 0) +{ + if (vt.is_string()) + { + std::string s = vt.as_string(); + std::stringstream ss(s); + float result = defaultValue; + ss >> result; + return result; + } + return defaultValue; +} + +static std::string MetadataString(const json_variant &o, const std::vector &names) +{ + for (const auto &name : names) + { + auto result = (*o.as_object())[name]; + if (result.is_string()) + { + return result.as_string(); + } + } + return ""; +} + +static int32_t MetadataTrackToInt(const std::string &track, const std::string &disc) +{ + if (track.empty()) + { + return -1; + } + try + { + int trackNumber = std::stoi(track); + if (!disc.empty()) + { + int discNumber = std::stoi(disc); + trackNumber += discNumber + trackNumber; // e.g. disc 1 track 2 becomes 1002 + } + return trackNumber; + } + catch (const std::exception &) + { + return -1; // Invalid track or disc number + } +} + +AudioFileMetadata::AudioFileMetadata(const std::filesystem::path &file) +{ + try + { + const std::string json = GetJsonMetadata(file); + + json_variant vt; + + std::stringstream ss(json); + json_reader reader(ss); + + reader.read(&vt); + + auto top = vt.as_object(); + auto format = top->at("format").as_object(); + + this->duration_ = MetadataFloat(format->at("duration"), 0); + + auto tags = (*format)["tags"]; + if (tags.is_object()) + { + // not all of this data gets used (e.g. DATE, YEAR, ALBUM_ARTIST,TOTALTRACKS). But ... write once. + this->album_ = MetadataString(tags, {"ALBUM", "album"}); + //this->artist_ = MetadataString(tags, {"ARTIST", "artist"}); + //this->albumArtist_ = MetadataString(tags, {"ALBUM ARTIST", "album_artist", "album artist"}); + this->title_ = MetadataString(tags, {"TITLE", "title"}); + //this->date_ = MetadataString(tags, {"DATE", "date"}); + //this->year_ = MetadataString(tags, {"YEAR", "year"}); + this->track_ = + MetadataTrackToInt( + MetadataString(tags, {"track","TRACK"}), + MetadataString(tags, {"disc", "DISC"}) + ); + //this->totalTracks_ = MetadataString(tags, {"TOTALTRACKS"}); + + if (title_ == "") + { + this->title_ = file.stem(); + } + } + } + catch (const std::exception &e) + { + std::string title = file.stem(); + this->title_ = title; + } +} + +namespace +{ + class AudioCacheKey + { + public: + AudioCacheKey(const std::filesystem::path &path) + { + this->path = path.string(); + this->lastWrite = std::filesystem::last_write_time(path); + } + // Equality operator + bool operator==(const AudioCacheKey &other) const + { + return path == other.path && lastWrite == other.lastWrite; + } + + public: + std::string path; + fs::file_time_type lastWrite; + }; +} + +namespace std +{ + template <> + struct hash + { + + size_t operator()(const AudioCacheKey &key) const + { + size_t path_hash = hash{}(key.path); + size_t time_hash = hash{}( + duration_cast(key.lastWrite.time_since_epoch()).count()); + // Combine hashes (boost::hash_combine approach) + return path_hash ^ (time_hash + 0x9e3779b9 + (path_hash << 6) + (path_hash >> 2)); + } + }; +} + +static LRUCache metadataCache{500}; +static std::mutex metadataCacheMutex; + +std::string pipedal::GetAudioFileMetadataString(const std::filesystem::path &path) +{ + std::lock_guard lock{metadataCacheMutex}; + AudioCacheKey key{path}; + std::string result; + if (metadataCache.get(key, result)) + { + return result; + } + auto metadata = AudioFileMetadata(path); + + std::stringstream ss; + json_writer writer(ss); + writer.write(metadata); + result = ss.str(); + + metadataCache.put(key, result); + return result; +} + +TemporaryFile pipedal::GetAudioFileThumbnail(const std::filesystem::path &path, const std::filesystem::path &outputPath) +{ + return GetAudioFileThumbnail(path, 0, 0, outputPath); +} + +std::mutex thumbnailMutex; + +TemporaryFile pipedal::GetAudioFileThumbnail(const std::filesystem::path &path, int32_t width, int32_t height, const std::filesystem::path &tempDirectory) +{ + std::lock_guard lock(thumbnailMutex); + fs::path thumbnailDirectory = tempDirectory / "thumbnails"; + if (!fs::exists(thumbnailDirectory)) + { + fs::create_directories(thumbnailDirectory); + } else { + // Clean up old thumbnails + for (const auto &entry : fs::directory_iterator(thumbnailDirectory)) + { + if (entry.is_regular_file()) + { + fs::remove(entry.path()); + } + } + } + fs::create_directories(tempDirectory); + + TemporaryFile tempFile(tempDirectory); + std::filesystem::path outputPath = thumbnailDirectory / "thumbnail-%03d.jpg"; + // ffmpeg -loglevel error -i "test2.mp3" -vf scale=200:200 -frames:v 1 thumb-%03.jpg -y + + std::stringstream ss; + ss << "/usr/bin/ffmpeg -loglevel error -i " << shell_escape_filename(path.string()); + if (width > 0 && height > 0) + { + ss << " -vf \"scale=" << width << ":" << height << + ":force_original_aspect_ratio=increase,crop=" << width << ":" << height << "\""; + } else { + // -vf "crop=min(iw\,ih):min(iw\,ih)" + ss << " -vf \"crop=min(iw\\,ih):min(iw\\,ih)\""; + } + ss << " " << shell_escape_filename(outputPath.string()); + ss << " -y 2>/dev/null 1>/dev/null"; + std::string command = ss.str(); + int rc = system(command.c_str()); + if (rc < 0) { + throw std::runtime_error("Failed to execute ffmpeg command: " + command); + } + int exitCode = WEXITSTATUS(rc); + if (exitCode != EXIT_SUCCESS) + { throw std::runtime_error("Thumbnail not foud."); + } + fs::remove(tempFile.Path()); + if (fs::exists(thumbnailDirectory / "thumbnail-001.jpg")) + { + // Move the thumbnail to the temporary file. + fs::rename(thumbnailDirectory / "thumbnail-001.jpg", tempFile.Path()); + } + else + { + throw std::runtime_error("Failed to create thumbnail."); + } + return tempFile; +} + diff --git a/vite/src/pipedal/AudioFileMetadaa.tsx b/src/AudioFileMetadataReader.hpp similarity index 66% rename from vite/src/pipedal/AudioFileMetadaa.tsx rename to src/AudioFileMetadataReader.hpp index 6df5cfa..74cc107 100644 --- a/vite/src/pipedal/AudioFileMetadaa.tsx +++ b/src/AudioFileMetadataReader.hpp @@ -8,10 +8,10 @@ * 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 @@ -21,24 +21,22 @@ * SOFTWARE. */ +#pragma once -export default class AudioFileMetadata { - //AudioFileMetadata&operator=(const AudioFileMetadata&) = default; - deserialize(o: any) { - this.title = o.title; - this.duration = o.duration; - this.track = o.track; - this.album = o.album; - this.disc = o.disc; - this.artist = o.artist; - this.albumArtist = o.albumArtist; - return this; - } - duration: number = 0; - title: string = ""; - track: string = ""; - album: string = ""; - disc: string = ""; - artist: string = ""; - albumArtist: string = ""; +#include +#include +#include "TemporaryFile.hpp" +#include "AudioFileMetadata.hpp" + +namespace pipedal +{ + + + TemporaryFile GetAudioFileThumbnail(const std::filesystem::path &path, int32_t width, int32_t height, const std::filesystem::path &tempDirectory); + TemporaryFile GetAudioFileThumbnail(const std::filesystem::path &path, const std::filesystem::path &tempDirectory); + + std::string GetAudioFileMetadataString(const std::filesystem::path &path); + + + } diff --git a/src/AudioFiles.cpp b/src/AudioFiles.cpp index c4a35d1..a51ca00 100644 --- a/src/AudioFiles.cpp +++ b/src/AudioFiles.cpp @@ -8,10 +8,10 @@ * 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 @@ -21,128 +21,697 @@ * SOFTWARE. */ +#include "AudioFiles.hpp" +#include +#include +#include +#include "AudioFileMetadataReader.hpp" +#include "Locale.hpp" +#include "MimeTypes.hpp" +#include +#include "AudioFilesDb.hpp" - #include "AudioFiles.hpp" - #include - #include - - - - using namespace pipedal; +#undef _GLIBCXX_DEBUG // Ensure we are not in debug mode, as this file is not compatible with it. +#include "SQLiteCpp/SQLiteCpp.h" +using namespace pipedal; +using namespace pipedal::impl; +namespace fs = std::filesystem; static constexpr size_t INVALID_INDEX = std::numeric_limits::max(); - namespace fs = std::filesystem; - - namespace { - class DirectoryList { - public: - DirectoryList(const fs::path&path); - - const std::vector &files() { - return files_; - } - private: - - std::vector files_; - }; - - class AudioFileInfoImpl: public AudioFileInfo { - public: - virtual ~AudioFileInfoImpl() { } - protected: - virtual std::string GetNextAudioFile(const std::string&path) override; - virtual std::string GetPreviousAudioFile(const std::string&path) override; - }; - ///////////////////// - - DirectoryList::DirectoryList(const fs::path&path) +void AudioDirectoryInfo::SetTemporaryDirectory(const std::filesystem::path &path) +{ + temporaryDirectory = path; +} +void AudioDirectoryInfo::SetResourceDirectory(const std::filesystem::path &path) +{ + resourceDirectory = path; +} +std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() const +{ + if (temporaryDirectory.empty()) { - for (const auto&dirEntry: fs::directory_iterator(path)) + return "/tmp/pipedal/audiofiles"; // used during testing. + } + return temporaryDirectory; +} +std::filesystem::path AudioDirectoryInfo::GetResourceDirectory() const +{ + if (resourceDirectory.empty()) + { + fs::path path = fs::current_path() / "vite/public/img"; // take from the source directory during testing. + return "/usr/share/pipedal/audiofiles"; // used during testing. + } + return resourceDirectory; +} + +std::filesystem::path AudioDirectoryInfo::temporaryDirectory; +std::filesystem::path AudioDirectoryInfo::resourceDirectory; + +namespace +{ + + static int64_t fileTimeToInt64(const fs::file_time_type &fileTime) + { + return fileTime.time_since_epoch().count(); + } + static int64_t GetLastWriteTime(const fs::path &file) + { + try { - if (dirEntry.is_regular_file()) + return fileTimeToInt64(fs::last_write_time(file)); + } + catch (const std::exception &e) + { + return 0; // Return 0 if we cannot get the last write time. + } + } + + class AudioDirectoryInfoImpl : public AudioDirectoryInfo + { + public: + AudioDirectoryInfoImpl(const std::string &path) + : path(path) + { + } + virtual ~AudioDirectoryInfoImpl() {} + + virtual std::vector GetFiles() override; + virtual ThumbnailTemporaryFile GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) override; + + virtual ThumbnailTemporaryFile DefaultThumbnailTemporaryFile() override; + + virtual size_t TestGetNumberOfThumbnails() override; // test use only. + virtual void TestSetIndexPath(const std::filesystem::path &path) override + { + indexPath = path; + } + virtual std::string GetNextAudioFile(const std::string &fileNameOnly) override; + virtual std::string GetPreviousAudioFile(const std::string &fileNameOnly) override; + + private: + std::filesystem::path indexPath; + void OpenAudioDb(); + ThumbnailTemporaryFile GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height); + + std::shared_ptr audioFilesDb; + fs::path path; + using id_t = int64_t; + + std::vector QueryTracks(); + + std::vector UpdateDbFiles(); + void DbSetThumbnailType( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + void DbSetThumbnailType( + const std::string &fileNameOnly, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + + void DbDeleteFile(DbFileInfo *dbFile); + void UpdateMetadata(DbFileInfo *dbFile); + static constexpr int DB_VERSION = 1; + std::filesystem::path GetFolderFile() const; + }; +} + + +AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::string &path) +{ + return std::shared_ptr( + new AudioDirectoryInfoImpl(path)); +} + +std::vector AudioDirectoryInfoImpl::GetFiles() +{ + OpenAudioDb(); + std::vector dbFiles = UpdateDbFiles(); + std::vector metadataResults; + for (const auto &dbFile : dbFiles) + { + AudioFileMetadata metadata{dbFile}; // slice copy of just the metadata. + metadataResults.push_back(std::move(metadata)); + } + return metadataResults; +} + +std::vector AudioDirectoryInfoImpl::QueryTracks() +{ + if (audioFilesDb) + { + return audioFilesDb->QueryTracks(); + } + return std::vector(); +} +static bool isAudioExtension(const std::string &extension) +{ + return MimeTypes::instance().AudioExtensions().contains(extension); +} + +static bool HasPositionInfo(const std::vector &dbFiles) +{ + for (const auto &file : dbFiles) + { + if (file.position() != -1) + { + return true; + } + } + return false; +} +static int DefaultedSortOrder(int track) +{ + if (track < 0) + { + return std::numeric_limits::max(); + } + return track; +} +static void SortDbFiles(std::vector &dbFiles, Collator::ptr &collator) +{ + std::sort( + dbFiles.begin(), dbFiles.end(), + [collator](const DbFileInfo &a, const DbFileInfo &b) + { + if (a.position() != b.position()) { - fs::path t = dirEntry.path(); - if (!t.filename().string().starts_with('.')) + return DefaultedSortOrder(a.position()) < DefaultedSortOrder(b.position()); + } + if (a.track() != b.track()) + { + return DefaultedSortOrder(a.track()) < DefaultedSortOrder(b.track()); + } + if (a.title() != b.title()) + { + return collator->Compare(a.title(), b.title()) < 0; + } + if (a.album() != b.album()) + { + return collator->Compare(a.title(), b.title()) < 0; + } + return false; + ; + }); +} + +std::vector AudioDirectoryInfoImpl::UpdateDbFiles() +{ + std::shared_ptr transaction; + if (audioFilesDb) + { + transaction = audioFilesDb->transaction(); + } + std::vector dbFiles = QueryTracks(); + std::vector newFiles; + + bool updateRequired = false; + std::map nameToDbRecord; + for (size_t i = 0; i < dbFiles.size(); ++i) + { + auto *pFile = &(dbFiles[i]); + nameToDbRecord[pFile->fileName()] = pFile; + } + + for (auto dirEntry : fs::directory_iterator(path)) + { + if (!dirEntry.is_directory()) + { + auto path = dirEntry.path(); + std::string name = path.filename(); + std::string extension = path.extension(); + if (isAudioExtension(extension)) + { + int64_t lastModified = fileTimeToInt64(dirEntry.last_write_time()); + + auto f = nameToDbRecord.find(name); + if (f != nameToDbRecord.end()) { - files_.push_back(std::move(t)); + DbFileInfo *dbFile = f->second; + dbFile->present(true); + if (dbFile->lastModified() != lastModified) + { + if (audioFilesDb) + { + audioFilesDb->DeleteThumbnails(dbFile->idFile()); + } + dbFile->thumbnailType(ThumbnailType::Unknown); + dbFile->thumbnailFile(""); + dbFile->thumbnailLastModified(0); + UpdateMetadata(dbFile); + dbFile->dirty(true); + updateRequired = true; + } + } + else + { + DbFileInfo newFile; + newFile.fileName(name); + newFile.idFile(-1); + newFile.dirty(true); + newFile.present(true); + UpdateMetadata(&newFile); + newFiles.push_back(std::move(newFile)); + updateRequired = true; } } } } - - std::string AudioFileInfoImpl::GetNextAudioFile(const std::string&path_) { - try + for (auto i = dbFiles.begin(); i != dbFiles.end(); ++i) + { + if (!i->present()) { - fs::path path {path_}; - if (fs::exists(path) && fs::is_regular_file(path)) - { - DirectoryList directoryList(path.parent_path()); - size_t index = INVALID_INDEX; - auto &files = directoryList.files(); - for (size_t i= 0; i < files.size(); ++i) - { - if (files[i] == path) - { - size_t next = i; - if (next == files.size()-1) - { - next = 0; - } else { - ++next; - } - return files[next]; - } - } - - } - } catch (const std::exception&e) - { - + DbDeleteFile(&*i); + dbFiles.erase(i); + updateRequired = true; + --i; } - return ""; - } - std::string AudioFileInfoImpl::GetPreviousAudioFile(const std::string&path_) + // refresh folder file names. + auto folderFile = GetFolderFile(); + int64_t folderLastModified = 0; + std::string folderFileName; + if (!folderFile.empty() && fs::exists(folderFile)) + { + folderLastModified = GetLastWriteTime(folderFile); + folderFileName = folderFile.filename().string(); + } + for (auto &dbFile : dbFiles) + { + if (dbFile.thumbnailType() == ThumbnailType::Unknown) + { + if (!folderFileName.empty()) + { + dbFile.thumbnailType(ThumbnailType::Folder); + dbFile.thumbnailFile(folderFileName); + dbFile.thumbnailLastModified(folderLastModified); + dbFile.dirty(true); + updateRequired = true; + } + } + else if (dbFile.thumbnailType() == ThumbnailType::Folder) + { + // Check if the folder file has changed. + if (folderFileName.empty()) + { + // no more folder file. update accordingly. + dbFile.thumbnailType(ThumbnailType::None); + dbFile.thumbnailFile(""); + dbFile.thumbnailLastModified(0); + dbFile.dirty(true); + updateRequired = true; + } + else + { + // folder file has changed. update accordingly. + if (dbFile.thumbnailFile() != folderFileName || + dbFile.thumbnailLastModified() != folderLastModified) + { + dbFile.thumbnailFile(folderFileName); + dbFile.thumbnailLastModified(folderLastModified); + dbFile.dirty(true); + updateRequired = true; + } + } + } + } + + if (!updateRequired && newFiles.empty()) + { + // Nothing to do. + return dbFiles; + } + + Locale::ptr locale = Locale::GetInstance(); + Collator::ptr collator = locale->GetCollator(); + if (!HasPositionInfo(dbFiles)) + { + dbFiles.insert(dbFiles.end(), newFiles.begin(), newFiles.end()); + SortDbFiles(dbFiles, collator); + } + else + { + // We have position info, so we need to update the position. + SortDbFiles(dbFiles, collator); + SortDbFiles(newFiles, collator); + dbFiles.insert(dbFiles.end(), newFiles.begin(), newFiles.end()); + + for (size_t i = 0; i < dbFiles.size(); ++i) + { + auto newPosition = static_cast(i); + if (newPosition != dbFiles[i].position()) + { + dbFiles[i].dirty(true); + } + dbFiles[i].position(newPosition); + } + } + for (auto &dbFile : dbFiles) + { + if (dbFile.dirty()) + { + if (audioFilesDb) + { + audioFilesDb->WriteFile(&dbFile); + } + dbFile.dirty(false); + } + } + if (transaction) + { + transaction->commit(); + transaction = nullptr; + } + return dbFiles; +} + +void AudioDirectoryInfoImpl::DbDeleteFile(DbFileInfo *dbFile) +{ + if (this->audioFilesDb) + { + audioFilesDb->DeleteFile(dbFile); + } +} + +// All the varous cover art files I found on my personal music collection. +// These are used to find the cover art for a directory. Based on +// scanning a large music collection that have been tagged by various tools +// including iTunes, Windows Media Playe, and a variety of taggers. +// +// Files are listed in order of preference. + +static std::vector COVER_ART_FILES = { + "Folder.jpg", // window media player/explorer. + "Cover.jpg", // itunes. + "folder.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "cover.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "Artwork.jpg", // itunes. + "Front.jpg", + "front.jpg", // linux. + "AlbumArt.jpg", + "albumArt.jpg", + "Frontcover.jpg", + "AlbumArtSmall.jpg"}; + +fs::path AudioDirectoryInfoImpl::GetFolderFile() const +{ + for (const auto &coverArtFile : COVER_ART_FILES) + { + fs::path thumbnailPath = path / coverArtFile; + if (fs::exists(thumbnailPath)) + { + return thumbnailPath; + } + } + return {}; +} + +void AudioDirectoryInfoImpl::DbSetThumbnailType( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (audioFilesDb) + { + audioFilesDb->UpdateThumbnailInfo( + idFile, + thumbnailType, + thumbnailFile, + thumbnailLastModified); + } +} +void AudioDirectoryInfoImpl::DbSetThumbnailType( + const std::string&fileNameOnly, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (audioFilesDb) + { + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + thumbnailType, + thumbnailFile, + thumbnailLastModified); + } +} + +static ThumbnailTemporaryFile GetFolderTemporaryFile(const fs::path &folderFile) +{ + ThumbnailTemporaryFile result; + result.SetNonDeletedPath(folderFile, MimeTypes::instance().MimeTypeFromExtension(folderFile.extension().string())); + return result; +} +ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) +{ + fs::path file = this->path / fileNameOnly; + OpenAudioDb(); + if (audioFilesDb) { try { - fs::path path {path_}; - if (fs::exists(path) && fs::is_regular_file(path)) + + ThumbnailInfo thumbnailInfo = audioFilesDb->GetThumbnailInfo(file.filename().string()); + + if (thumbnailInfo.thumbnailType() == ThumbnailType::Folder) { - DirectoryList directoryList(path.parent_path()); - size_t index = INVALID_INDEX; - auto &files = directoryList.files(); - for (size_t i= 0; i < files.size(); ++i) + fs::path thumbnailFile = path / thumbnailInfo.thumbnailFile(); + if (!fs::exists(thumbnailFile) || + fileTimeToInt64(fs::last_write_time(thumbnailFile)) != + thumbnailInfo.thumbnailLastModified()) { - if (files[i] == path) - { - size_t previous = i; - if (previous == 0) - { - previous = files.size()-1; - } else { - --previous; - } - return files[previous]; - } + // reset the thumbnail type and try again. + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + ThumbnailType::Unknown); + return GetThumbnail(fileNameOnly, width, height); } + fs::path fullFolderPath = this->path / thumbnailInfo.thumbnailFile(); + return GetFolderTemporaryFile(fullFolderPath); + } + if (thumbnailInfo.thumbnailType() == ThumbnailType::Embedded) + { + std::vector blob = audioFilesDb->GetEmbeddedThumbnail(fileNameOnly, width, height); - } - } catch (const std::exception&e) - { + if (!blob.empty()) + { + ThumbnailTemporaryFile tempFile = ThumbnailTemporaryFile::CreateTemporaryFile(GetTemporaryDirectory(), "image/jpeg"); + tempFile.SetMimeType("image/jpeg"); + // write buffer to tempFile.GetPath() + std::ofstream outFile(tempFile.Path(), std::ios::binary); + if (!outFile) + { + throw std::runtime_error("Failed to open temporary file for writing: " + tempFile.Path().string()); + } + outFile.write((const char *)blob.data(), blob.size()); + return tempFile; + } + else + { + // fall through and generate the indexed thumbnail. + } + } + + if (thumbnailInfo.thumbnailType() == ThumbnailType::None) + { + return DefaultThumbnailTemporaryFile(); + } + + try + { + auto tempFile = pipedal::GetAudioFileThumbnail( + this->path / fileNameOnly, + width, height, + GetTemporaryDirectory()); + auto thumbnailPath = tempFile.Path(); + ThumbnailTemporaryFile result; + result.Attach(tempFile.Detach(), "image/jpeg"); + + // Read the thumbnail data from the temporary file. + std::ifstream thumbnailStream(thumbnailPath, std::ios::binary); + if (!thumbnailStream) + { + throw std::runtime_error("Failed to open thumbnail file: " + thumbnailPath.string()); + } + std::vector thumbnailData( + (std::istreambuf_iterator(thumbnailStream)), + std::istreambuf_iterator()); + + if (!thumbnailData.empty()) + { + audioFilesDb->AddThumbnail( + fileNameOnly, + width, + height, + thumbnailData); + + // Set the MIME type for the thumbnail. + result.SetMimeType("image/jpeg"); + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + ThumbnailType::Embedded); + return result; + } + else + { + throw std::runtime_error("Thumbnail data is empty."); + } + return result; + } + catch (const std::exception &_) + { + } + auto folderFile = GetFolderFile(); + if (!folderFile.empty()) + { + // We have a folder thumbnail, set the type and return it. + fs::path t = this->path / folderFile; + int64_t lastModified = fileTimeToInt64(fs::last_write_time(t)); + DbSetThumbnailType(fileNameOnly, ThumbnailType::Folder, folderFile, lastModified); + auto fullFolderPath = this->path / folderFile; + return GetFolderTemporaryFile(fullFolderPath); + } + DbSetThumbnailType( + fileNameOnly, + ThumbnailType::None); // No thumbnail available, set to None. + return DefaultThumbnailTemporaryFile(); + } + catch (const std::exception &e) + { } - return ""; - } + return GetUnindexedThunbnail(fileNameOnly, width, height); +} - } +ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height) +{ + fs::path file = this->path / fileNameOnly; + ThumbnailTemporaryFile tempFile = ThumbnailTemporaryFile::CreateTemporaryFile(GetTemporaryDirectory(), "image/jpeg"); + try + { + auto thumbnailPath = pipedal::GetAudioFileThumbnail(file, width, height, GetTemporaryDirectory()); + tempFile.Attach(thumbnailPath.Detach(), "image/jpeg"); + return tempFile; + } + catch (const std::exception &e) + { + // If we cannot get the thumbnail, return a default one. + return DefaultThumbnailTemporaryFile(); + } +} - static AudioFileInfoImpl instance; +void AudioDirectoryInfoImpl::UpdateMetadata(DbFileInfo *dbFile) +{ + fs::path file = this->path / dbFile->fileName(); + AudioFileMetadata metadata(file); + dbFile->title(metadata.title()); + dbFile->track(metadata.track()); + dbFile->duration(metadata.duration()); + dbFile->album(metadata.album()); + dbFile->lastModified(GetLastWriteTime(file)); + dbFile->duration(metadata.duration()); +} - AudioFileInfo&AudioFileInfo::GetInstance() - { - return instance; - } \ No newline at end of file +ThumbnailTemporaryFile::ThumbnailTemporaryFile(const std::filesystem::path &temporaryDirectory) + : TemporaryFile(temporaryDirectory) +{ + // Set a default MIME type for the thumbnail. + mimeType = "image/jpeg"; // Default MIME type, can be set later. +} + +ThumbnailTemporaryFile ThumbnailTemporaryFile::CreateTemporaryFile(const std::filesystem::path &temporaryDirectory, const std::string &mimeType) +{ + ThumbnailTemporaryFile result(temporaryDirectory); + result.SetMimeType(mimeType); + return result; +} + +ThumbnailTemporaryFile ThumbnailTemporaryFile::FromFile(const std::filesystem::path &filePath, const std::string &mimeType) +{ + ThumbnailTemporaryFile result; + result.Attach(filePath, mimeType); + return result; +} + +void ThumbnailTemporaryFile::Attach(const std::filesystem::path &filePath, const std::string &mimeType) +{ + super::Attach(filePath); + SetMimeType(mimeType); +} + +size_t AudioDirectoryInfoImpl::TestGetNumberOfThumbnails() +{ + if (!audioFilesDb) + { + throw std::logic_error("DB not open"); + } + return this->audioFilesDb->GetNumberOfThumbnails(); +} + +void AudioDirectoryInfoImpl::OpenAudioDb() +{ + if (!this->audioFilesDb) { + this->audioFilesDb = std::make_shared(this->path, indexPath); + } +} + +ThumbnailTemporaryFile AudioDirectoryInfoImpl::DefaultThumbnailTemporaryFile() +{ + ThumbnailTemporaryFile tempFile; + fs::path defaultThumbnail = GetResourceDirectory() / "img/missing_thumbnail.jpg"; + tempFile.SetNonDeletedPath(defaultThumbnail, "image/jpeg"); + return tempFile; +} + +std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly) +{ + auto files = this->GetFiles(); + if (files.empty()) + { + return ""; + } + for (size_t i = 0; i < files.size(); ++i) + { + if (files[i].fileName() == fileNameOnly) + { + if (i + 1 < files.size()) + { + return files[i + 1].fileName(); + } + else + { + return files[0].fileName(); // wrap around to the first file. + } + } + } + return ""; +} +std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &fileNameOnly) +{ + auto files = this->GetFiles(); + if (files.empty()) + { + return ""; + } + for (size_t i = 0; i < files.size(); ++i) + { + if (files[i].fileName() == fileNameOnly) + { + if (i > 0) + { + return files[i - 1].fileName(); + } + else + { + return files[files.size() - 1].fileName(); // wrap around to the last file. + } + } + } + return ""; +} diff --git a/src/AudioFiles.hpp b/src/AudioFiles.hpp index 05401b2..0f54295 100644 --- a/src/AudioFiles.hpp +++ b/src/AudioFiles.hpp @@ -24,16 +24,82 @@ #pragma once #include +#include +#include +#include +#include +#include "AudioFileMetadata.hpp" +#include "TemporaryFile.hpp" namespace pipedal { - class AudioFileInfo { - protected: - AudioFileInfo() { } - virtual ~AudioFileInfo() {} - public: - static AudioFileInfo&GetInstance(); - virtual std::string GetNextAudioFile(const std::string&path) = 0; - virtual std::string GetPreviousAudioFile(const std::string&path) = 0; + class ThumbnailTemporaryFile: public TemporaryFile { + private: + // Private constructor to enforce the use of CreateTemporaryFile or the other constructor. + explicit ThumbnailTemporaryFile(const std::filesystem::path&temporaryDirectory); + public: + using super = TemporaryFile; + ThumbnailTemporaryFile() = default; + ThumbnailTemporaryFile(const ThumbnailTemporaryFile&) = delete; + ThumbnailTemporaryFile(ThumbnailTemporaryFile&&) = default; + ThumbnailTemporaryFile&operator=(const ThumbnailTemporaryFile&) = delete; + ThumbnailTemporaryFile&operator=(ThumbnailTemporaryFile&&) = default; +public: + static ThumbnailTemporaryFile CreateTemporaryFile(const std::filesystem::path&tempDirectory,const std::string &mimeType); + static ThumbnailTemporaryFile FromFile(const std::filesystem::path&filePath,const std::string &mimeType); +public: + + void SetNonDeletedPath(const std::filesystem::path&path, const std::string&mimeType = "audio/mpeg") { + TemporaryFile::SetNonDeletedPath(path); + // Set the MIME type for audio files. + this->mimeType = mimeType; // Default MIME type, can be set later. + } + void Attach(const std::filesystem::path&path, const std::string&mimeType); + + std::string GetMimeType() { + return mimeType; + } + void SetMimeType(const std::string&mimeType) { + this->mimeType = mimeType; + } + private: + std::string mimeType = "image/jpeg"; // Default MIME type, can be set later. + }; -} \ No newline at end of file + + class AudioDirectoryInfo { + protected: + AudioDirectoryInfo() { } + virtual ~AudioDirectoryInfo() { } + public: + using self = AudioDirectoryInfo; + using Ptr = std::shared_ptr; + + static Ptr Create(const std::string&path); + + virtual std::vector GetFiles() = 0; + + virtual ThumbnailTemporaryFile GetThumbnail(const std::string&fileNameOnly, int32_t width, int32_t height) = 0; + + virtual std::string GetNextAudioFile(const std::string&fileNameOnly) = 0; + virtual std::string GetPreviousAudioFile(const std::string&fileNameOnly) = 0; + + virtual ThumbnailTemporaryFile DefaultThumbnailTemporaryFile() = 0; + + static void SetTemporaryDirectory(const std::filesystem::path&path); + static void SetResourceDirectory(const std::filesystem::path&path); + + + virtual size_t TestGetNumberOfThumbnails() = 0; // test use only. + virtual void TestSetIndexPath(const std::filesystem::path&path) = 0; + + protected: + std::filesystem::path GetTemporaryDirectory() const; + std::filesystem::path GetResourceDirectory() const; + + private: + static std::filesystem::path temporaryDirectory; + static std::filesystem::path resourceDirectory;; + + }; + } \ No newline at end of file diff --git a/src/AudioFilesDb.cpp b/src/AudioFilesDb.cpp new file mode 100644 index 0000000..a9915a3 --- /dev/null +++ b/src/AudioFilesDb.cpp @@ -0,0 +1,482 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AudioFilesDb.hpp" +#include +#include +#include +#include + +using namespace pipedal; +using namespace pipedal::impl; +namespace fs = std::filesystem; + +namespace pipedal::impl +{ + struct DatabaseLockEntry + { + virtual ~DatabaseLockEntry() = default; + + using ptr = std::shared_ptr; + + std::mutex mutex; + }; + static std::map> databaseLocks; + + std::mutex databaseLockMutex; + + class DatabaseLock + { + private: + fs::path indexFile; + std::shared_ptr lockEntry; + + // Disable copy and move constructors + DatabaseLock(const DatabaseLock &) = delete; + DatabaseLock &operator=(const DatabaseLock &) = delete; + DatabaseLock(DatabaseLock &&) = delete; + DatabaseLock &operator=(DatabaseLock &&) = delete; + + public: + explicit DatabaseLock(const fs::path &lockFilePath) + : indexFile(lockFilePath) + { + { + std::lock_guard lock(databaseLockMutex); + + auto it = databaseLocks.find(indexFile); + if (it == databaseLocks.end()) + { + // Create a new lock entry + DatabaseLockEntry::ptr entry = std::make_shared(); + databaseLocks[indexFile] = entry; + lockEntry = entry; + } + else + { + lockEntry = it->second; + } + } + lockEntry->mutex.lock(); // we now have exclusive access to the database. + + } + + ~DatabaseLock() + { + lockEntry->mutex.unlock(); // release the lock + { + std::lock_guard lock(databaseLockMutex); + auto use_count = lockEntry.use_count(); + + if (use_count == 2) // one for us, one for the Lockentry + { + // Remove the entry from the map if no other locks are held. + databaseLocks.erase(indexFile); + } + + } + + } + }; +} + +static std::unique_ptr getDatabaseLock(const std::filesystem::path &path) +{ + // Create a lock file in the same directory as the database. + return std::make_unique(path); +} + +AudioFilesDb::AudioFilesDb( + const std::filesystem::path &path, + const std::filesystem::path &indexPath) + : path(path) +{ + fs::path dbPathName = this->path / ".pipedal.index"; + if (!indexPath.empty()) + { + dbPathName = indexPath; + } + + dbLock = getDatabaseLock(dbPathName); + + if (!fs::exists(dbPathName)) + { + CreateDb(dbPathName); + } + else + { + this->db = std::make_unique(dbPathName, SQLite::OPEN_READWRITE); + UpgradeDb(); + } +} + +void AudioFilesDb::UpgradeDb() +{ + int version = QueryVersion(); +} + +int AudioFilesDb::QueryVersion() +{ + SQLite::Statement query(*db, "SELECT version FROM am_dbInfo LIMIT 1"); + + // Fetch the result + if (query.executeStep()) + { + int version = query.getColumn(0).getInt(); + return version; + } + else + { + throw std::runtime_error("QueryVersion failed."); + } +} + +void AudioFilesDb::CreateDb(const std::filesystem::path &dbPathName) +{ + try + { + + this->db = std::make_unique(dbPathName, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); + + try + { + db->exec("CREATE TABLE am_dbInfo (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "version INTEGER NOT NULL)"); + + { + SQLite::Statement query(*db, "INSERT INTO am_dbInfo (version) VALUES (?)"); + query.bind(1, DB_VERSION); + query.exec(); + } + + db->exec( + "CREATE TABLE IF NOT EXISTS files (" + "idFile INTEGER PRIMARY KEY AUTOINCREMENT, " + "fileName TEXT NOT NULL, " + "lastModified INT64 NOT NULL," + "title TEXT NOT NULL," + "track NUMBER NOT NULL," + "album TEXT NOT NULL, " + "duration REAL NOT NULL DEFAULT 0.0," + + "thumbnailType INTEGER NOT NULL DEFAULT 0," + "position INTEGER NOT NULL DEFAULT -1," + "thumbnailFile TEXT NOT NULL DEFAULT \"\"," + "thumbnailLastModified INT64 NOT NULL DEFAULT 0" + ")"); + + db->exec("CREATE TABLE IF NOT EXISTS thumbnails (" + "idThumbnail INTEGER PRIMARY KEY AUTOINCREMENT, " + "idFile INT64, " + "thumbnail BLOB, " + "width INTEGER, " + "height INTEGER)"); + } + catch (const SQLite::Exception &e) + { + throw std::runtime_error("Failed to create database tables: " + std::string(e.what())); + } + } + catch (const SQLite::Exception &e) + { + throw std::runtime_error("Failed to create database: " + std::string(e.what())); + } +} + +void AudioFilesDb::DeleteFile(DbFileInfo *dbFile) +{ + DeleteThumbnails(dbFile->idFile()); + if (!deleteFileQuery) + { + deleteFileQuery = std::make_unique( + *db, + "DELETE FROM files WHERE idFile = ?"); + } + deleteFileQuery->tryReset(); + deleteFileQuery->bind(1, dbFile->idFile()); + deleteFileQuery->exec(); +} + +void AudioFilesDb::DeleteThumbnails(id_t idFile) +{ + if (!updateThumbnailInfoQueryByName) + { + updateThumbnailInfoQueryByName = std::make_unique( + *db, + "DELETE FROM thumbnails WHERE idFile = ?"); + } + updateThumbnailInfoQueryByName->tryReset(); + updateThumbnailInfoQueryByName->bind(1, idFile); + updateThumbnailInfoQueryByName->exec(); +} + +size_t AudioFilesDb::GetNumberOfThumbnails() +{ + SQLite::Statement query(*db, "SELECT COUNT(*) FROM thumbnails"); + if (query.executeStep()) + { + return query.getColumn(0).getInt64(); + } + return 0; +} + +void AudioFilesDb::UpdateThumbnailInfo( + const std::string &fileName, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (!updateThumbnailInfoQueryByName) + { + updateThumbnailInfoQueryByName = std::make_unique( + *db, + "UPDATE files SET thumbnailType = ?,thumbnailFile = ?, thumbnailLastModified = ? WHERE fileName = ?"); + } + updateThumbnailInfoQueryByName->tryReset(); + updateThumbnailInfoQueryByName->bind(1, (int32_t)thumbnailType); + updateThumbnailInfoQueryByName->bind(2, thumbnailFile); + updateThumbnailInfoQueryByName->bind(3, thumbnailLastModified); + updateThumbnailInfoQueryByName->bind(4, fileName); + updateThumbnailInfoQueryByName->exec(); +} + +void AudioFilesDb::UpdateThumbnailInfo( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (!updateThumbnailInfoQueryById) + { + updateThumbnailInfoQueryById = std::make_unique( + *db, + "UPDATE files SET thumbnailType = ?,thumbnailFile = ?, thumbnailLastModified = ? WHERE idFile = ?"); + } + updateThumbnailInfoQueryById->bind(1, (int32_t)thumbnailType); + updateThumbnailInfoQueryById->bind(2, thumbnailFile); + updateThumbnailInfoQueryById->bind(3, thumbnailLastModified); + updateThumbnailInfoQueryById->bind(4, idFile); + updateThumbnailInfoQueryById->exec(); +} + +std::unique_ptr AudioFilesDb::transaction() +{ + return std::make_unique(*db); +} + +std::vector AudioFilesDb::QueryTracks() +{ + std::vector result; + // get tracks fro the databse. + SQLite::Statement query( + *db, + "SELECT idFile, fileName, " + "lastModified, title, track, album,duration, " + "thumbnailType, position, " + "thumbnailFile, thumbnailLastModified " + "FROM files "); + + while (query.executeStep()) + { + DbFileInfo row; + row.idFile(query.getColumn(0).getInt64()); + row.fileName(query.getColumn(1).getText()); + row.lastModified(query.getColumn(2).getInt64()); + row.title(query.getColumn(3).getText()); + row.track(query.getColumn(4).getInt()); + row.album(query.getColumn(5).getText()); + row.duration(query.getColumn(6).getDouble()); + row.thumbnailType((ThumbnailType)query.getColumn(7).getInt()); + row.position(query.getColumn(8).getInt()); + row.thumbnailFile(query.getColumn(9).getText()); + row.thumbnailLastModified(query.getColumn(10).getInt64()); + result.push_back(std::move(row)); + } + return result; +} + +void AudioFilesDb::WriteFile(DbFileInfo *dbFile) +{ + if (dbFile->idFile() == -1) + { + if (!insertFileQuery) + { + insertFileQuery = std::make_unique( + *db, + "INSERT INTO files (" + "fileName, lastModified," + "title,track,album, " + "duration, thumbnailType,thumbnailFile, thumbnailLastModified, position " + ") VALUES (?, ?, ?, ?, ?,?,?,?,?,?)"); + } + insertFileQuery->tryReset(); + insertFileQuery->bind(1, dbFile->fileName()); + insertFileQuery->bind(2, dbFile->lastModified()); + insertFileQuery->bind(3, dbFile->title()); + insertFileQuery->bind(4, dbFile->track()); + insertFileQuery->bind(5, dbFile->album()); + insertFileQuery->bind(6, dbFile->duration()); + insertFileQuery->bind(7, (int32_t)dbFile->thumbnailType()); + insertFileQuery->bind(8, dbFile->thumbnailFile()); + insertFileQuery->bind(9, dbFile->thumbnailLastModified()); + insertFileQuery->bind(10, dbFile->position()); + insertFileQuery->exec(); + dbFile->idFile(db->getLastInsertRowid()); + } + else + { + if (!updateFileQuery) + { + updateFileQuery = std::make_unique( + *db, + "UPDATE files SET " + "fileName = ?, lastModified = ?, " + "title = ?, track = ?, album = ?, " + "duration = ?, thumbnailType = ?, " + "thumbnailFile = ?, thumbnailLastModified = ?, position = ? " + " WHERE idFile = ?"); + } + updateFileQuery->tryReset(); + updateFileQuery->bind(1, dbFile->fileName()); + updateFileQuery->bind(2, dbFile->lastModified()); + updateFileQuery->bind(3, dbFile->title()); + updateFileQuery->bind(4, dbFile->track()); + updateFileQuery->bind(5, dbFile->album()); + updateFileQuery->bind(6, dbFile->duration()); + updateFileQuery->bind(7, (int32_t)dbFile->thumbnailType()); + updateFileQuery->bind(8, dbFile->thumbnailFile()); + updateFileQuery->bind(9, dbFile->thumbnailLastModified()); + updateFileQuery->bind(10, dbFile->position()); + + updateFileQuery->bind(11, dbFile->idFile()); + + updateFileQuery->exec(); + } +} + +ThumbnailInfo AudioFilesDb::GetThumbnailInfo(const std::string &fileNameOnly) +{ + ThumbnailInfo result; + SQLite::Statement query( + *db, + "SELECT thumbnailType, thumbnailFile, thumbnailLastModified FROM files WHERE fileName = ?"); + query.bind(1, fileNameOnly); + if (query.executeStep()) + { + result.thumbnailType((ThumbnailType)query.getColumn(0).getInt()); + result.thumbnailFile(query.getColumn(1).getText()); + result.thumbnailLastModified(query.getColumn(2).getInt64()); + return result; + } + else + { + throw std::runtime_error("No thumbnail info found for file."); + } + return result; +} + +std::vector AudioFilesDb::GetEmbeddedThumbnail(int64_t idFile, int32_t width, int32_t height) +{ + SQLite::Statement query(*db, + "SELECT thumbnail FROM thumbnails " + "WHERE idFile = ? AND width = ? AND height = ?"); + query.bind(1, idFile); + query.bind(2, width); + query.bind(3, height); + + if (query.executeStep()) + { + const uint8_t *data = (uint8_t *)query.getColumn(0).getBlob(); + int size = query.getColumn(0).getBytes(); + return std::vector(data, data + size); + } + else + { + return {}; + } +} + +std::vector AudioFilesDb::GetEmbeddedThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) +{ + int64_t idFile; + SQLite::Statement query(*db, "SELECT idFile FROM files WHERE fileName = ?"); + query.bind(1, fileNameOnly); + if (query.executeStep()) + { + idFile = query.getColumn(0).getInt64(); + } + else + { + throw std::runtime_error("File not found in database: " + fileNameOnly); + } + return GetEmbeddedThumbnail(idFile, width, height); +} + +void AudioFilesDb::AddThumbnail( + const std::string &fileName, + int64_t width, + int64_t height, + const std::vector &thumbnailData) +{ + int64_t idFile; + SQLite::Statement query(*db, "SELECT idFile FROM files WHERE fileName = ?"); + query.bind(1, fileName); + if (query.executeStep()) + { + idFile = query.getColumn(0).getInt64(); + } + else + { + throw std::runtime_error("File not found in database: " + fileName); + } + AddThumbnail(idFile, width, height, thumbnailData); +} +void AudioFilesDb::AddThumbnail( + int64_t idFile, + int64_t width, + int64_t height, + const std::vector &thumbnailData) +{ + if (thumbnailData.empty()) + { + throw std::runtime_error("Thumbnail data is empty."); + } + + SQLite::Statement insertQuery( + *db, + "INSERT INTO thumbnails (idFile, thumbnail, width, height) VALUES (?, ?, ?, ?)"); + insertQuery.bind(1, idFile); + insertQuery.bind(2, thumbnailData.data(), thumbnailData.size()); + insertQuery.bind(3, width); + insertQuery.bind(4, height); + + try + { + insertQuery.exec(); + } + catch (const SQLite::Exception &e) + { + throw std::runtime_error("Failed to add thumbnail: " + std::string(e.what())); + } +} diff --git a/src/AudioFilesDb.hpp b/src/AudioFilesDb.hpp new file mode 100644 index 0000000..1188e32 --- /dev/null +++ b/src/AudioFilesDb.hpp @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +#pragma once + +#include +#include +#include "SQLiteCpp/SQLiteCpp.h" +#include "AudioFileMetadata.hpp" +#include +#include + + +namespace pipedal::impl { + + class DatabaseLock; + + class DbFileInfo : public AudioFileMetadata + { + private: + int64_t idFile_ = -1; + bool present_ = false; + bool dirty_ = false; + public: + int64_t idFile() const { return idFile_;} + void idFile(int64_t value) { idFile_ = value;} + bool present() const { return present_; } + void present(bool value) { present_ = value;} + bool dirty() const { return dirty_;} + void dirty(bool value) { dirty_ = value;} + + }; + + + class ThumbnailInfo { + public: + ThumbnailInfo() = default; + private: + ThumbnailType thumbnailType_ = ThumbnailType::Unknown; + std::string thumbnailFile_; + int64_t thumbnailLastModified_ = 0; + public: + ThumbnailType thumbnailType() const { return thumbnailType_; } + void thumbnailType(ThumbnailType value) { thumbnailType_ = value; } + const std::string &thumbnailFile() const { return thumbnailFile_; } + void thumbnailFile(const std::string &value) { thumbnailFile_ = value; } + int64_t thumbnailLastModified() const { return thumbnailLastModified_; } + void thumbnailLastModified(int64_t value) { thumbnailLastModified_ = value; } + }; + class AudioFilesDb { + public: + static constexpr int32_t DB_VERSION = 1; + AudioFilesDb( + const std::filesystem::path &path, + const std::filesystem::path &indexPath = "" // if non-empty, forces the location of the ".index.pipedal" file. + ); + public: + void DeleteFile(DbFileInfo *dbFile); + void WriteFile(DbFileInfo *dbFile); + + // test only. + size_t GetNumberOfThumbnails(); + void UpdateThumbnailInfo( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + void UpdateThumbnailInfo( + const std::string&fileName, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + std::unique_ptr transaction(); + std::vector QueryTracks(); + void DeleteThumbnails(id_t idFile); + ThumbnailInfo GetThumbnailInfo(const std::string &fileNameOnly); + std::vector GetEmbeddedThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height); + std::vector GetEmbeddedThumbnail(int64_t idFile, int32_t width, int32_t height); + void AddThumbnail( + const std::string &fileName, + int64_t width, + int64_t height, + const std::vector &thumbnailData); + void AddThumbnail( + int64_t idFile, + int64_t width, + int64_t height, + const std::vector &thumbnailData); + + private: + + void CreateDb(const std::filesystem::path &dbPathName); + void UpgradeDb(); + int QueryVersion(); + + private: + std::filesystem::path indexPath; + + // warning: destructor order matters here! + std::shared_ptr dbLock; // mutex to protect the index file. + std::unique_ptr db; + std::unique_ptr insertFileQuery; + std::unique_ptr updateFileQuery; + std::unique_ptr deleteFileQuery; + std::unique_ptr updateThumbnailInfoQueryByName; + std::unique_ptr updateThumbnailInfoQueryById; + std::filesystem::path path; + }; +} + diff --git a/src/AudioFilesTest.cpp b/src/AudioFilesTest.cpp new file mode 100644 index 0000000..3073b1b --- /dev/null +++ b/src/AudioFilesTest.cpp @@ -0,0 +1,536 @@ +// 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 +#include +#include "AudioFiles.hpp" +#include +#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 &files, const std::string &trackName) +{ + for (const auto &file : files) + { + if (file.title() == trackName) + { + return true; + } + } + return false; +} +static bool ContainsFile(const std::vector &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 &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 &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(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(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 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(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 +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7281a22..6b744db 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -46,7 +46,6 @@ include(FindPkgConfig) find_package(sdbus-c++ REQUIRED) -# find_package(SQLiteCpp REQUIRED) @@ -153,7 +152,8 @@ add_compile_definitions(DEBIAN_ARCHITECTURE="${DEBIAN_ARCHITECTURE}") if (CMAKE_BUILD_TYPE MATCHES Debug) message(STATUS "Debug build") - set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -g -DDEBUG -D_GLIBCXX_DEBUG" ) + # Must not -D_GLIBCXX_DEBUG, since it conflict with SQLiteCpp.lol + set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -g -DDEBUG " ) if (USE_SANITIZE) set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -static-libasan " ) endif() @@ -197,7 +197,9 @@ endif() set (PIPEDAL_SOURCES AudioFiles.cpp AudioFiles.hpp - AudioFileMetadata.cpp AudioFileMetadata.hpp + AudioFileMetadataReader.cpp AudioFileMetadataReader.hpp + AudioFileMetadata.hpp AudioFileMetadata.cpp + AudioFilesDb.hpp AudioFilesDb.cpp LRUCache.hpp CpuTemperatureMonitor.cpp CpuTemperatureMonitor.hpp SchedulerPriority.hpp SchedulerPriority.cpp @@ -332,7 +334,10 @@ add_library(libpipedald STATIC ${PIPEDAL_SOURCES}) target_include_directories(libpipedald PRIVATE ${PIPEDAL_INCLUDES} ) -target_link_libraries(libpipedald PUBLIC PiPedalCommon) +target_link_libraries(libpipedald + PUBLIC + PiPedalCommon + SQLiteCpp) if(${USE_PCH}) target_precompile_headers(libpipedald PRIVATE pch.h) @@ -355,7 +360,7 @@ target_link_libraries(pipedal_kconfig PRIVATE ftxui::component # Not needed for this example. PRIVATE PiPedalCommon systemd -) +) ################################# add_executable(pipedald @@ -391,6 +396,7 @@ add_executable(AuxInTest add_executable(pipedaltest testMain.cpp + AudioFilesTest.cpp LRUCacheTest.cpp ModFileTypesTest.cpp jsonTest.cpp diff --git a/src/FileEntry.cpp b/src/FileEntry.cpp index 3c77073..8cdde41 100644 --- a/src/FileEntry.cpp +++ b/src/FileEntry.cpp @@ -26,6 +26,7 @@ JSON_MAP_BEGIN(FileEntry) JSON_MAP_REFERENCE(FileEntry,displayName) JSON_MAP_REFERENCE(FileEntry,isProtected) JSON_MAP_REFERENCE(FileEntry,isDirectory) + JSON_MAP_REFERENCE(FileEntry,metadata) JSON_MAP_END() JSON_MAP_BEGIN(BreadcrumbEntry) diff --git a/src/FileEntry.hpp b/src/FileEntry.hpp index c040955..f1a7241 100644 --- a/src/FileEntry.hpp +++ b/src/FileEntry.hpp @@ -20,6 +20,7 @@ #pragma once #include "json.hpp" +#include "AudioFileMetadata.hpp" namespace pipedal { @@ -30,11 +31,19 @@ namespace pipedal { :pathname_(pathname), displayName_(displayName), isDirectory_(isDirectory),isProtected_(isProtected) { + } + FileEntry(const std::string&pathname,const std::string &displayName, bool isProtected, + std::shared_ptr metadata + ) + :pathname_(pathname), displayName_(displayName), isDirectory_(false),isProtected_(isProtected),metadata_(metadata) + { + } std::string pathname_; std::string displayName_; bool isDirectory_ = false; bool isProtected_ = false; + std::shared_ptr metadata_; DECLARE_JSON_MAP(FileEntry); diff --git a/src/MimeTypes.cpp b/src/MimeTypes.cpp index 86c3c99..e98b804 100644 --- a/src/MimeTypes.cpp +++ b/src/MimeTypes.cpp @@ -58,19 +58,38 @@ static std::string toLower(const std::string&value) return result; } +const std::set playListExtensions = { + ".m3u", + ".m3u8", + ".pls", + ".wpl" +}; + +const std::set midiMimeTypes = { + "audio/midi", + "audio/sp-midi", + "audio/imelody", + "audio/xmf", + "audio/x-midi", + "audio/x-musicxml+xml" +}; + void MimeTypes::AddMimeType(const std::string&extension_, const std::string&mimeType) { std::string extension = SS("." << toLower(extension_)); mimeTypeToExtensions[mimeType].insert(extension); mimeTypeToExtension[mimeType] = extension; extensionToMimeType[extension] = mimeType; - if (mimeType.starts_with("audio/midi")) + if (midiMimeTypes.contains(mimeType)) { midiExtensions.insert(extension); } else if (mimeType.starts_with("audio/")) { - mimeTypeToExtensions["audio/*"].insert(extension); - audioExtensions.insert(extension); + if (!playListExtensions.contains(extension)) + { + mimeTypeToExtensions["audio/*"].insert(extension); + audioExtensions.insert(extension); + } } if (mimeType.starts_with("video/")) { diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 0a97e0f..86ae020 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -2364,6 +2364,8 @@ FileRequestResult PiPedalModel::GetFileList2(const std::string &relativePath_, c { if (!storage.IsInUploadsDirectory(relativePath)) { + + // if relativePath is in a resource directory of the plugin, then we have loaded a factory preset or are using a default property. // map the resource path to the corresponding file in the uploads directory. // :-( diff --git a/src/Storage.cpp b/src/Storage.cpp index 8fa628f..ae7f0e6 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -37,6 +37,7 @@ #include #include #include "util.hpp" +#include "AudioFiles.hpp" using namespace pipedal; namespace fs = std::filesystem; @@ -46,7 +47,6 @@ const char *BANKS_FILENAME = "index.banks"; #define USER_SETTINGS_FILENAME "userSettings.json"; - static bool hasSyntheticModRoot(const UiFileProperty &fileProperty) { return (fileProperty.modDirectories().size() > 1 || (fileProperty.modDirectories().size() == 1 && fileProperty.useLegacyModDirectory())); @@ -1600,7 +1600,6 @@ static void ThrowPermissionDeniedError() throw std::logic_error("Permission denied."); } - static bool ensureNoDotDot(const std::filesystem::path &path) { for (auto segment_ : path) @@ -1627,13 +1626,13 @@ static bool ensureNoDotDot(const std::filesystem::path &path) return true; } - static void AddFilesToResult( FileRequestResult &result, - const ModFileTypes::ModDirectory *modDirectoryInfo, //yyx + const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx const UiFileProperty &fileProperty, const fs::path &rootPath) { + if (!fs::exists(rootPath)) { return; // silently without error. @@ -1641,10 +1640,9 @@ static void AddFilesToResult( auto &resultFiles = result.files_; std::set validExtensions = fileProperty.GetPermittedFileExtensions( - modDirectoryInfo? modDirectoryInfo->modType: ""); + modDirectoryInfo ? modDirectoryInfo->modType : ""); - - try + try { for (auto const &dir_entry : std::filesystem::directory_iterator(rootPath)) { @@ -1657,10 +1655,9 @@ static void AddFilesToResult( bool match = false; if (name.length() > 0 && name[0] != '.') // don't show hidden files. { - bool match = validExtensions.size() == 0 || validExtensions.contains(extension) - || validExtensions.contains(".*"); + bool match = validExtensions.size() == 0 || validExtensions.contains(extension) || validExtensions.contains(".*"); - if (match) + if (match && !name.starts_with(".")) { resultFiles.push_back( FileEntry(path, name, false, false)); @@ -1690,6 +1687,57 @@ static void AddFilesToResult( } return collator->Compare(l.displayName_,r.displayName_) < 0; }); } + +static void AddTracksToResult( + FileRequestResult &result, + const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx + const UiFileProperty &fileProperty, + const fs::path &rootPath) +{ + + if (!fs::exists(rootPath)) + { + return; // silently without error. + } + auto &resultFiles = result.files_; + + std::set validExtensions = fileProperty.GetPermittedFileExtensions( + modDirectoryInfo ? modDirectoryInfo->modType : ""); + + try + { + // Add directories first. + for (auto const &dir_entry : std::filesystem::directory_iterator(rootPath)) + { + const auto &path = dir_entry.path(); + auto name = path.filename().string(); + if (dir_entry.is_directory()) + { + resultFiles.push_back(FileEntry{path, name, true, fs::is_symlink(path)}); + } + } + auto audioFiles = AudioDirectoryInfo::Create(rootPath); + for (const auto &audioFile : audioFiles->GetFiles()) + { + fs::path audioFilePath = rootPath / audioFile.fileName(); + std::string extension = UiFileProperty::GetFileExtension(audioFilePath); + if (validExtensions.size() == 0 || validExtensions.contains(extension) || validExtensions.contains(".*")) + { + resultFiles.push_back( + FileEntry( + audioFilePath, + audioFile.title(), + false, + std::make_shared(audioFile))); + } + } + } + catch (const std::exception &error) + { + throw std::logic_error("GetFileList failed. Directory not found: " + rootPath.string()); + } +} + FileRequestResult Storage::GetModFileList2(const std::string &relativePath, const UiFileProperty &fileProperty) { FileRequestResult result; @@ -1771,13 +1819,20 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons } } - - AddFilesToResult(result,rootModDirectory, fileProperty, relativePath); + if (IsInAudioTracksDirectory(relativePath)) + { + AddTracksToResult(result, rootModDirectory, fileProperty, relativePath); + } + else + { + AddFilesToResult(result, rootModDirectory, fileProperty, relativePath); + } result.currentDirectory_ = relativePath; return result; } -static bool IsChildDirectory(const fs::path& child, const fs::path&parent) { +static bool IsChildDirectory(const fs::path &child, const fs::path &parent) +{ auto iChild = child.begin(); for (auto i = parent.begin(); i != parent.end(); ++i) { @@ -1824,17 +1879,15 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const } } - if (!IsChildDirectory(absolutePath,pluginRootDirectory)) + if (!IsChildDirectory(absolutePath, pluginRootDirectory)) { absolutePath = pluginRootDirectory; } - result.currentDirectory_ = absolutePath; - { - // watch out for resource files!!! + // watch out for resource files!!! result.breadcrumbs_.push_back({"", "Home"}); fs::path fsAbsolutePath{absolutePath}; auto iAbsolutePath = fsAbsolutePath.begin(); @@ -1860,13 +1913,20 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const throw std::runtime_error(SS("Improper location. " << absolutePath)); } - const ModFileTypes::ModDirectory*pModDirectory = nullptr; + const ModFileTypes::ModDirectory *pModDirectory = nullptr; if (fileProperty.modDirectories().size() > 0) { pModDirectory = ModFileTypes::GetModDirectory(fileProperty.modDirectories()[0]); } - AddFilesToResult(result,pModDirectory, fileProperty, absolutePath); + if (IsInAudioTracksDirectory(absolutePath)) + { + AddTracksToResult(result, pModDirectory, fileProperty, absolutePath); + } + else + { + AddFilesToResult(result, pModDirectory, fileProperty, absolutePath); + } return result; } @@ -1960,10 +2020,10 @@ std::filesystem::path Storage::MakeUserFilePath(const std::string &directory, co } return result; } -std::string Storage::UploadUserFile(const std::string &directory, - UiFileProperty::ptr uiFileProperty, - const std::string &filename, - std::istream &stream, size_t contentLength) +std::string Storage::UploadUserFile(const std::string &directory, + UiFileProperty::ptr uiFileProperty, + const std::string &filename, + std::istream &stream, size_t contentLength) { std::filesystem::path path; if (directory.length() != 0) @@ -1975,9 +2035,11 @@ std::string Storage::UploadUserFile(const std::string &directory, throw std::logic_error("Directory argument not supplied."); } fs::path relativePath; - try { - relativePath = MakeRelativePath(path,this->GetPluginUploadDirectory()); - } catch (const std::exception& e) + try + { + relativePath = MakeRelativePath(path, this->GetPluginUploadDirectory()); + } + catch (const std::exception &e) { throw std::logic_error("Permission denied. Path is outside the upload storage directory."); } @@ -1986,7 +2048,6 @@ std::string Storage::UploadUserFile(const std::string &directory, { throw std::logic_error("Permission denied. Invalid file extension for this directory."); } - { try @@ -2106,26 +2167,23 @@ void Storage::FillSampleDirectoryTree(FilePropertyDirectoryTree *node, const std }); } - - -static void GetAllExtensions(std::set&result, const std::filesystem::path &path) +static void GetAllExtensions(std::set &result, const std::filesystem::path &path) { assert(fs::is_directory(path)); - for (const auto&dirEnt : fs::directory_iterator(path)) + for (const auto &dirEnt : fs::directory_iterator(path)) { if (dirEnt.is_directory()) { - GetAllExtensions(result,dirEnt.path()); - } else { + GetAllExtensions(result, dirEnt.path()); + } + else + { std::string filename = dirEnt.path().filename(); - if (!filename.starts_with('.')) + if (dirEnt.path().has_extension()) { - if (dirEnt.path().has_extension()) - { - std::string extension = dirEnt.path().extension(); - result.insert(extension); - } + std::string extension = dirEnt.path().extension(); + result.insert(extension); } } } @@ -2134,28 +2192,33 @@ static void GetAllExtensions(std::set&result, const std::filesystem static std::set GetAllExtensions(const std::filesystem::path &path) { std::set result; - if (fs::is_regular_file(path)) { + if (fs::is_regular_file(path)) + { result.insert(UiFileProperty::GetFileExtension(path)); - } else { - GetAllExtensions(result,path); + } + else + { + GetAllExtensions(result, path); } return result; } -FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty, const std::filesystem::path&selectedPath) +FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty, const std::filesystem::path &selectedPath) { fs::path uploadDirectory = this->GetPluginUploadDirectory(); fs::path relativePath; - try { - relativePath = MakeRelativePath(selectedPath,uploadDirectory); - } catch (const std::exception&e) { + try + { + relativePath = MakeRelativePath(selectedPath, uploadDirectory); + } + catch (const std::exception &e) + { // not an upload directory. throw std::runtime_error(SS("Permission denied: " << selectedPath)); } std::set fileExtensions = GetAllExtensions(selectedPath); - if (hasSyntheticModRoot(uiFileProperty)) { @@ -2172,10 +2235,10 @@ FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFil auto modDirectoryInfo = ModFileTypes::GetModDirectory(modDirectory); if (modDirectoryInfo) { - if (IsSubset(fileExtensions,modDirectoryInfo->fileExtensions) || + if (IsSubset(fileExtensions, modDirectoryInfo->fileExtensions) || modDirectoryInfo->fileExtensions.contains(".*") || modDirectoryInfo->fileExtensions.empty() // Private directory that accepts files of any type. - ) + ) { auto childPath = uploadDirectory / modDirectoryInfo->pipedalPath; FilePropertyDirectoryTree::ptr child = std::make_unique( @@ -2213,9 +2276,16 @@ FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFil } } -bool Storage::IsInUploadsDirectory(const std::filesystem::path&path) const +bool Storage::IsInAudioTracksDirectory(const std::filesystem::path &path) const { - return IsSubdirectory(path,this->GetPluginUploadDirectory()); + std::filesystem::path audioTracksDirectory = + this->GetPluginUploadDirectory() / "shared/audio/Tracks"; + return IsSubdirectory(path, audioTracksDirectory); +} + +bool Storage::IsInUploadsDirectory(const std::filesystem::path &path) const +{ + return IsSubdirectory(path, this->GetPluginUploadDirectory()); } const PluginPresetIndex &Storage::GetPluginPresetIndex() diff --git a/src/Storage.hpp b/src/Storage.hpp index 0e50aa8..41c4640 100644 --- a/src/Storage.hpp +++ b/src/Storage.hpp @@ -155,6 +155,7 @@ public: int64_t DeleteBank(int64_t bankId); bool IsInUploadsDirectory(const std::filesystem::path&path) const; + bool IsInAudioTracksDirectory(const std::filesystem::path&path) const; FileRequestResult GetFileList2(const std::string&relativePath,const UiFileProperty&fileProperty); diff --git a/src/TemporaryFile.cpp b/src/TemporaryFile.cpp index 39b91d9..4e9488c 100644 --- a/src/TemporaryFile.cpp +++ b/src/TemporaryFile.cpp @@ -52,14 +52,36 @@ TemporaryFile::TemporaryFile(const std::filesystem::path&directory) this->path = filename; } -void TemporaryFile::Detach() { - this->path.clear(); +std::filesystem::path TemporaryFile::Detach() { + std::filesystem::path result = std::move(this->path); + return result; } TemporaryFile::~TemporaryFile() { - if (!path.empty()) + if (!path.empty() && deleteFile) { std::filesystem::remove(path); } } +void TemporaryFile::SetNonDeletedPath(const std::filesystem::path&path) +{ + this->path = path; + this->deleteFile = false; // Do not delete the file on destruction +} + + + +TemporaryFile::TemporaryFile(TemporaryFile&&other) { + this->path = std::move(other.path); + this->deleteFile = other.deleteFile; +} +TemporaryFile&TemporaryFile::operator=(TemporaryFile&&other) { + if (!this->path.empty() && this->deleteFile) { + std::filesystem::remove(this->path); + this->path.clear(); + } + this->path = std::move(other.path); + this->deleteFile = other.deleteFile; + return *this; +} diff --git a/src/TemporaryFile.hpp b/src/TemporaryFile.hpp index e1095b2..1ff6df0 100644 --- a/src/TemporaryFile.hpp +++ b/src/TemporaryFile.hpp @@ -25,18 +25,25 @@ namespace pipedal { TemporaryFile() {} TemporaryFile(const TemporaryFile&) = delete; TemporaryFile&operator=(const TemporaryFile&) = delete; + TemporaryFile(TemporaryFile&&other); + TemporaryFile&operator=(TemporaryFile&&other); + public: + explicit TemporaryFile(const std::filesystem::path&parentDirectory); - TemporaryFile(TemporaryFile&&other) { - this->path = std::move(other.path); + void Attach(const std::filesystem::path&path) { + this->path = path; + deleteFile = true; // default to deleting the file on destruction. } - TemporaryFile(const std::filesystem::path&parentDirectory); + void SetNonDeletedPath(const std::filesystem::path&path); - void Detach(); + bool DeleteFile() const { return deleteFile; } + std::filesystem::path Detach(); ~TemporaryFile(); const std::filesystem::path&Path()const { return path;} std::string str() const { return path.c_str(); } const char*c_str() const { return path.c_str(); } private: + bool deleteFile = true; std::filesystem::path path; }; } \ No newline at end of file diff --git a/src/WebServer.cpp b/src/WebServer.cpp index 0ad2718..946088f 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -738,7 +738,7 @@ namespace pipedal virtual void setBodyFile(std::shared_ptr&bodyFile) override { // cast away const to do what ther request would do if it had that method. - request.set_body_file(bodyFile->Path(),true); + request.set_body_file(bodyFile->Path(),bodyFile->DeleteFile()); bodyFile->Detach(); } virtual void setBodyFile(std::filesystem::path&path, bool deleteWhenDone) override { diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index da104dc..69f967f 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -35,10 +35,9 @@ #include "json.hpp" #include "HotspotManager.hpp" #include "MimeTypes.hpp" -#include "AudioFileMetadata.hpp" +#include "AudioFileMetadataReader.hpp" #include "AudioFiles.hpp" - #define OLD_PRESET_EXTENSION ".piPreset" #define PRESET_EXTENSION ".piPreset" #define OLD_BANK_EXTENSION ".piBank" @@ -90,6 +89,16 @@ static std::string GetMimeType(const std::filesystem::path &path) auto result = mimeTypes.MimeTypeFromExtension(extension); return result; } + +int32_t ConvertThumbnailSize(const std::string ¶m) +{ + if (param.empty()) + { + return 0; + } + return static_cast (std::stoi(param)); +} + static bool IsZipFile(const std::filesystem::path &path) { std::ifstream f(path); @@ -185,10 +194,48 @@ public: else if (segment == "AudioMetadata") { return true; - } else if (segment == "NextAudioFile") + } + else if (segment == "Thumbnail") { return true; - } else if (segment == "PreviousAudioFile") + } + else if (segment == "PluginPresets") + { + return true; + } + else if (segment == "PluginPreset") + { + return true; + } + else if (segment == "PluginBanks") + { + return true; + } + else if (segment == "PluginBank") + { + return true; + } + else if (segment == "GetPluginInfo") + { + return true; + } + else if (segment == "GetPluginPresets") + { + return true; + } + else if (segment == "GetPreset") + { + return true; + } + else if (segment == "GetBank") + { + return true; + } + else if (segment == "NextAudioFile") + { + return true; + } + else if (segment == "PreviousAudioFile") { return true; } @@ -437,62 +484,136 @@ public: } else if (segment == "AudioMetadata") { + res.set(HttpField::content_type, "application/json"); - // res.set(HttpField::cache_control, "no-cache"); + res.set(HttpField::cache_control, "no-cache"); fs::path path = request_uri.query("path"); if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) { throw PiPedalException("File not found."); } - std::string metadata = GetAudioFileMetadataString(path); - res.setBody(metadata); + AudioDirectoryInfo::Ptr audioDirectory = AudioDirectoryInfo::Create(path.parent_path()); + + auto files = audioDirectory->GetFiles(); + for (const auto &file : files) + { + std::string fileNameOnly = path.filename(); + if (file.fileName() == fileNameOnly) + { + std::stringstream ss; + json_writer writer(ss); + writer.write(file); + res.setBody(ss.str()); + return; + } + } + // If we get here, the file was not found in the directory. + throw PiPedalException("File not found in directory."); + } + else if (segment == "Thumbnail") + { + try + { + fs::path path = request_uri.query("path"); + + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + + int32_t width = ConvertThumbnailSize(request_uri.query("w")); + int32_t height = ConvertThumbnailSize(request_uri.query("h")); + + AudioDirectoryInfo::Ptr audioDirectory = AudioDirectoryInfo::Create(path.parent_path()); + audioDirectory->GetFiles(); // ensure that the .index file is up to date. + ThumbnailTemporaryFile thumbnail; + try { + thumbnail = audioDirectory->GetThumbnail(path.filename(), width, height); + } catch (const std::exception &e) { + thumbnail = audioDirectory->DefaultThumbnailTemporaryFile(); + } + res.set(HttpField::content_type, thumbnail.GetMimeType()); + + res.set(HttpField::cache_control, "max-age=300"); + res.set(HttpField::content_length, std::to_string(fs::file_size(thumbnail.Path()))); + + std::filesystem::path t = thumbnail.Path(); + res.setBodyFile(t,thumbnail.DeleteFile()); + thumbnail.Detach(); + } + catch (const std::exception &e) + { + Lv2Log::error("Error getting thumbnail: %s", e.what()); + res.set(HttpField::location, "/img/missing_thumbnail.jpg"); + // response = 307 + throw e; + } } else if (segment == "NextAudioFile") { res.set(HttpField::content_type, "application/json"); - // res.set(HttpField::cache_control, "no-cache"); + res.set(HttpField::cache_control, "no-cache"); fs::path path = request_uri.query("path"); - try { - if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) - { - throw PiPedalException("File not found."); - } - - std::string nextFile = AudioFileInfo::GetInstance().GetNextAudioFile(path); - std::stringstream ss; - json_writer writer(ss); - writer.write(nextFile); - - res.setBody(ss.str()); - } catch (const std::exception&e) + try { - res.setBody("''"); - } - } - else if (segment == "PreviousAudioFile") - { - res.set(HttpField::content_type, "application/json"); - // res.set(HttpField::cache_control, "no-cache"); - fs::path path = request_uri.query("path"); - - try { if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) { throw PiPedalException("File not found."); } - std::string previousFile = AudioFileInfo::GetInstance().GetPreviousAudioFile(path); + auto directoryPath = path.parent_path(); + auto directoryInfo = AudioDirectoryInfo::Create(directoryPath); + std::string result = directoryInfo->GetNextAudioFile(path.filename()); + if (!result.empty()) + { + result = (directoryPath / result).string(); + } + + // json-encode the result. std::stringstream ss; json_writer writer(ss); - writer.write(previousFile); + writer.write(result); res.setBody(ss.str()); - } catch (const std::exception&e) + } + catch (const std::exception &e) { res.setBody("\"\""); } - } + } + else if (segment == "PreviousAudioFile") + { + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + fs::path path = request_uri.query("path"); + + try + { + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + auto directoryPath = path.parent_path(); + auto directoryInfo = AudioDirectoryInfo::Create(directoryPath); + std::string result = directoryInfo->GetPreviousAudioFile(path.filename()); + if (!result.empty()) + { + result = (directoryPath / result).string(); + } + + // json-encode the result. + std::stringstream ss; + json_writer writer(ss); + writer.write(result); + + res.setBody(ss.str()); + } + catch (const std::exception &e) + { + res.setBody("\"\""); + } + } else { diff --git a/src/main.cpp b/src/main.cpp index cb2dfc7..68e02c3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -45,6 +45,7 @@ #include #include #include "SchedulerPriority.hpp" +#include "AudioFiles.hpp" #include @@ -120,7 +121,7 @@ int main(int argc, char *argv[]) { #ifndef WIN32 - umask(002); // newly created files in /var/pipedal get 7cd75-ish permissions, which improves debugging/live-service interaction. + umask(002); // newly created files in /var/pipedal get 775-ish permissions, which improves debugging/live-service interaction. #endif #if ENABLE_BACKTRACE @@ -234,6 +235,17 @@ int main(int argc, char *argv[]) configuration.SetSocketServerEndpoint(portOption); } + // clean up orphaned temporary files. + const std::filesystem::path webTempDirectory = "/var/pipedal/web_temp"; + if (!webTempDirectory.empty()) { + std::filesystem::remove_all(webTempDirectory); //// user must belong to the pipedald grop when debugging. + std::filesystem::create_directories(webTempDirectory); + } + + // configure AudiDirectoryInfo to use the correct directories. + AudioDirectoryInfo::SetResourceDirectory(configuration.GetWebRoot()); + AudioDirectoryInfo::SetTemporaryDirectory(webTempDirectory / "audiofiles"); + uint16_t port; std::shared_ptr server; try diff --git a/vite/CMakeLists.txt b/vite/CMakeLists.txt index 32b6825..214ac2c 100644 --- a/vite/CMakeLists.txt +++ b/vite/CMakeLists.txt @@ -45,7 +45,6 @@ add_custom_command( public/img/ic_logo.svg public/img/ic_navigate_next.svg public/img/fx_simulator.svg - public/img/VST_Logo_Steinberg.png public/img/fx_lr.svg public/img/fx_pitch.svg public/img/vst.svg @@ -93,7 +92,6 @@ add_custom_command( public/img/fx_gate.svg public/img/vst.png public/img/fx_function.svg - public/img/VST_Logo_Steinberg.ico public/img/fx_terminal.svg public/img/fx_plugin.svg public/img/fx_distortion.svg diff --git a/vite/index.html b/vite/index.html index 82218cc..2841b21 100644 --- a/vite/index.html +++ b/vite/index.html @@ -15,6 +15,7 @@ BODY { background: #D0D0D0; } +