Audio file metadata and thumbnails

This commit is contained in:
Robin E. R. Davies
2025-05-31 21:06:34 -04:00
parent 13e2f6b5bb
commit c4e0b0ff46
44 changed files with 3490 additions and 661 deletions
+1
View File
@@ -7,6 +7,7 @@
"${workspaceFolder}",
"${workspaceFolder}/build/src/**",
"${workspaceFolder}/src/**",
"${workspaceFolder}/modules/websocketpp/**",
"${workspaceFolder}/**",
"/usr/include/lilv-0",
"/usr/include/x86_64-linux-gnu",
+1
View File
@@ -22,6 +22,7 @@
// Resolved by CMake Tools:
"program": "${command:cmake.launchTargetPath}",
"args": [
"[ProfileThumbnails]"
],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
+1 -1
View File
@@ -16,7 +16,7 @@ enable_testing()
#add_subdirectory("submodules/pipedal_p2pd")
add_subdirectory("modules/SQLiteCpp")
add_subdirectory("PiPedalCommon")
add_subdirectory("vite")
+2 -2
View File
@@ -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.
+8 -2
View File
@@ -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");
+1 -1
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -207
View File
@@ -22,220 +22,21 @@
*/
#include "AudioFileMetadata.hpp"
#include <json.hpp>
#include <chrono>
#include <filesystem>
#include <json_variant.hpp>
#include "LRUCache.hpp"
#include <sstream>
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<std::string>& 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<AudioCacheKey> {
size_t operator()(const AudioCacheKey& key) const {
size_t path_hash = hash<std::string>{}(key.path);
size_t time_hash = hash<uintmax_t>{}(
duration_cast<std::chrono::nanoseconds>(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<AudioCacheKey,std::string> 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()
+43 -28
View File
@@ -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 <string>
#include <filesystem>
#include <string>
#include <cstdint>
#include <vector>
#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
}
}
+331
View File
@@ -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 <json.hpp>
#include <chrono>
#include <filesystem>
#include <json_variant.hpp>
#include "LRUCache.hpp"
#include <sstream>
#include <stdexcept>
#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<std::string> &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<AudioCacheKey>
{
size_t operator()(const AudioCacheKey &key) const
{
size_t path_hash = hash<std::string>{}(key.path);
size_t time_hash = hash<uintmax_t>{}(
duration_cast<std::chrono::nanoseconds>(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<AudioCacheKey, std::string> 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<std::mutex> 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;
}
@@ -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 <string>
#include <filesystem>
#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);
}
+667 -98
View File
@@ -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 <filesystem>
#include <limits>
#include <stdexcept>
#include "AudioFileMetadataReader.hpp"
#include "Locale.hpp"
#include "MimeTypes.hpp"
#include <stdexcept>
#include "AudioFilesDb.hpp"
#include "AudioFiles.hpp"
#include <filesystem>
#include <limits>
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<size_t>::max();
namespace fs = std::filesystem;
namespace {
class DirectoryList {
public:
DirectoryList(const fs::path&path);
const std::vector<std::filesystem::path> &files() {
return files_;
}
private:
std::vector<std::filesystem::path> 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<AudioFileMetadata> 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> audioFilesDb;
fs::path path;
using id_t = int64_t;
std::vector<DbFileInfo> QueryTracks();
std::vector<DbFileInfo> 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<AudioDirectoryInfo>(
new AudioDirectoryInfoImpl(path));
}
std::vector<AudioFileMetadata> AudioDirectoryInfoImpl::GetFiles()
{
OpenAudioDb();
std::vector<DbFileInfo> dbFiles = UpdateDbFiles();
std::vector<AudioFileMetadata> 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<DbFileInfo> AudioDirectoryInfoImpl::QueryTracks()
{
if (audioFilesDb)
{
return audioFilesDb->QueryTracks();
}
return std::vector<DbFileInfo>();
}
static bool isAudioExtension(const std::string &extension)
{
return MimeTypes::instance().AudioExtensions().contains(extension);
}
static bool HasPositionInfo(const std::vector<DbFileInfo> &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<int>::max();
}
return track;
}
static void SortDbFiles(std::vector<DbFileInfo> &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<DbFileInfo> AudioDirectoryInfoImpl::UpdateDbFiles()
{
std::shared_ptr<SQLite::Transaction> transaction;
if (audioFilesDb)
{
transaction = audioFilesDb->transaction();
}
std::vector<DbFileInfo> dbFiles = QueryTracks();
std::vector<DbFileInfo> newFiles;
bool updateRequired = false;
std::map<std::string, DbFileInfo *> 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<int32_t>(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<std::filesystem::path> 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<uint8_t> 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<uint8_t> thumbnailData(
(std::istreambuf_iterator<char>(thumbnailStream)),
std::istreambuf_iterator<char>());
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;
}
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<AudioFilesDb>(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 "";
}
+75 -9
View File
@@ -24,16 +24,82 @@
#pragma once
#include <string>
#include <memory>
#include <cstdint>
#include <vector>
#include <filesystem>
#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.
};
}
class AudioDirectoryInfo {
protected:
AudioDirectoryInfo() { }
virtual ~AudioDirectoryInfo() { }
public:
using self = AudioDirectoryInfo;
using Ptr = std::shared_ptr<self>;
static Ptr Create(const std::string&path);
virtual std::vector<AudioFileMetadata> 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;;
};
}
+482
View File
@@ -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 <stdexcept>
#include <atomic>
#include <mutex>
#include <map>
using namespace pipedal;
using namespace pipedal::impl;
namespace fs = std::filesystem;
namespace pipedal::impl
{
struct DatabaseLockEntry
{
virtual ~DatabaseLockEntry() = default;
using ptr = std::shared_ptr<DatabaseLockEntry>;
std::mutex mutex;
};
static std::map<std::filesystem::path, std::shared_ptr<DatabaseLockEntry>> databaseLocks;
std::mutex databaseLockMutex;
class DatabaseLock
{
private:
fs::path indexFile;
std::shared_ptr<DatabaseLockEntry> 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<std::mutex> lock(databaseLockMutex);
auto it = databaseLocks.find(indexFile);
if (it == databaseLocks.end())
{
// Create a new lock entry
DatabaseLockEntry::ptr entry = std::make_shared<DatabaseLockEntry>();
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<std::mutex> 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<DatabaseLock> getDatabaseLock(const std::filesystem::path &path)
{
// Create a lock file in the same directory as the database.
return std::make_unique<DatabaseLock>(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<SQLite::Database>(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<SQLite::Database>(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<SQLite::Statement>(
*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<SQLite::Statement>(
*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<SQLite::Statement>(
*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<SQLite::Statement>(
*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<SQLite::Transaction> AudioFilesDb::transaction()
{
return std::make_unique<SQLite::Transaction>(*db);
}
std::vector<DbFileInfo> AudioFilesDb::QueryTracks()
{
std::vector<DbFileInfo> 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<SQLite::Statement>(
*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<SQLite::Statement>(
*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<uint8_t> 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<uint8_t>(data, data + size);
}
else
{
return {};
}
}
std::vector<uint8_t> 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<uint8_t> &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<uint8_t> &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()));
}
}
+130
View File
@@ -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 <string>
#include <filesystem>
#include "SQLiteCpp/SQLiteCpp.h"
#include "AudioFileMetadata.hpp"
#include <cstdint>
#include <vector>
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<SQLite::Transaction> transaction();
std::vector<DbFileInfo> QueryTracks();
void DeleteThumbnails(id_t idFile);
ThumbnailInfo GetThumbnailInfo(const std::string &fileNameOnly);
std::vector<uint8_t> GetEmbeddedThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height);
std::vector<uint8_t> 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<uint8_t> &thumbnailData);
void AddThumbnail(
int64_t idFile,
int64_t width,
int64_t height,
const std::vector<uint8_t> &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<DatabaseLock> dbLock; // mutex to protect the index file.
std::unique_ptr<SQLite::Database> db;
std::unique_ptr<SQLite::Statement> insertFileQuery;
std::unique_ptr<SQLite::Statement> updateFileQuery;
std::unique_ptr<SQLite::Statement> deleteFileQuery;
std::unique_ptr<SQLite::Statement> updateThumbnailInfoQueryByName;
std::unique_ptr<SQLite::Statement> updateThumbnailInfoQueryById;
std::filesystem::path path;
};
}
+536
View File
@@ -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 <string>
#include <iostream>
#include "AudioFiles.hpp"
#include <filesystem>
#include "SysExec.hpp"
#include "json.hpp"
#include "json_variant.hpp"
#include "MimeTypes.hpp"
using namespace std;
using namespace pipedal;
namespace fs = std::filesystem;
fs::path testDir = fs::temp_directory_path() / "AudioFilesTest" / "files";
fs::path sourceDirectory = fs::current_path() / "artifacts" / "TestTracks";
fs::path tempDir = fs::temp_directory_path() / "AudioFilesTest" / "tmp";
fs::path resourceDir = fs::current_path() / "vite" / "public" / "img";
fs::path textIndexPath = fs::temp_directory_path() / "AudioFilesTest" / "TextIndex.pipedal";
static void CreateTestDirectory()
{
if (fs::exists(testDir))
{
fs::remove_all(testDir);
}
fs::remove_all(tempDir);
fs::create_directories(testDir);
if (fs::exists(tempDir))
{
fs::remove_all(tempDir);
}
fs::create_directories(tempDir);
AudioDirectoryInfo::SetTemporaryDirectory(tempDir);
AudioDirectoryInfo::SetResourceDirectory(resourceDir);
}
static void CheckForLeakedTemporaryFiles()
{
bool leakedFiles = false;
// Check if the temporary directory exists and is not empty
if (fs::exists(tempDir) && fs::is_directory(tempDir))
{
for (const auto &entry : fs::directory_iterator(tempDir))
{
if (entry.is_regular_file())
{
cout << "Leaked temporary file: " << entry.path() << endl;
leakedFiles = true;
}
}
}
if (fs::exists(tempDir) && fs::is_directory(tempDir / "thumbnails"))
{
for (const auto &entry : fs::directory_iterator(tempDir / "thumbnails"))
{
if (entry.is_regular_file())
{
cout << "Leaked temporary file: " << entry.path() << endl;
leakedFiles = true;
}
}
}
if (leakedFiles)
{
throw std::runtime_error("Leaked temporary files found in: " + tempDir.string());
}
}
static bool ContainsTrack(const std::vector<AudioFileMetadata> &files, const std::string &trackName)
{
for (const auto &file : files)
{
if (file.title() == trackName)
{
return true;
}
}
return false;
}
static bool ContainsFile(const std::vector<AudioFileMetadata> &files, const std::string &fileName)
{
for (const auto &file : files)
{
if (file.fileName() == fileName)
{
return true;
}
}
return false;
}
static const AudioFileMetadata *GetFile(const std::vector<AudioFileMetadata> &files, const std::string &fileName)
{
for (const auto &file : files)
{
if (file.fileName() == fileName)
{
return &file;
}
}
throw std::logic_error("File not found: " + fileName);
}
static void PrintFiles(const std::vector<AudioFileMetadata> &files)
{
for (const auto &file : files)
{
cout << "File: " << file.fileName() << ", Title: " << file.track() << ". " << file.title() << " Album: " << file.album() << endl;
}
cout << endl;
}
void IndexTest()
{
CreateTestDirectory();
try
{
{
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
}
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
REQUIRE(audioDir != nullptr);
if (fs::exists(testDir))
{
fs::remove_all(testDir); // Remove any existing database file
}
fs::create_directories(testDir);
// Copy test files to the test directory
for (const auto &entry : fs::directory_iterator(sourceDirectory))
{
if (entry.is_regular_file())
{
fs::copy(entry.path(), testDir / entry.path().filename());
}
}
auto files = audioDir->GetFiles();
PrintFiles(files);
REQUIRE(files.size() == 3);
REQUIRE(ContainsTrack(files, "Track 1"));
REQUIRE(ContainsTrack(files, "Track 2"));
REQUIRE(ContainsTrack(files, "Track 3"));
for (const auto &file : files)
{
REQUIRE(!file.fileName().empty());
}
// check indexed results.
files = audioDir->GetFiles();
REQUIRE(files.size() == 3);
for (const auto &file : files)
{
REQUIRE(!file.fileName().empty());
}
fs::remove(testDir / "Track2.mp3");
files = audioDir->GetFiles();
REQUIRE(files.size() == 2);
REQUIRE(!ContainsTrack(files, "Track 2"));
REQUIRE(ContainsTrack(files, "Track 1"));
REQUIRE(ContainsTrack(files, "Track 3"));
PrintFiles(files);
files = audioDir->GetFiles();
REQUIRE(files.size() == 2);
REQUIRE(!ContainsTrack(files, "Track 2"));
REQUIRE(ContainsTrack(files, "Track 1"));
REQUIRE(ContainsTrack(files, "Track 3"));
// check dbinex results.
fs::copy_file(sourceDirectory / "Track2.mp3", testDir / "Track3.mp3", fs::copy_options::overwrite_existing);
files = audioDir->GetFiles();
REQUIRE(files.size() == 2);
REQUIRE(ContainsTrack(files, "Track 2"));
REQUIRE(ContainsFile(files, "Track3.mp3"));
REQUIRE(!ContainsFile(files, "Track2.mp3"));
fs::copy_file(sourceDirectory / "Track3.mp3", testDir / "Track4.mp3", fs::copy_options::overwrite_existing);
files = audioDir->GetFiles();
REQUIRE(files.size() == 3);
REQUIRE(ContainsTrack(files, "Track 2"));
REQUIRE(ContainsFile(files, "Track3.mp3"));
REQUIRE(GetFile(files, "Track4.mp3")->title() == "Track 3");
{
auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1);
}
{
auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1);
}
{
auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 0, 0);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2);
}
CheckForLeakedTemporaryFiles();
}
catch (const std::exception &e)
{
cout << "Exception: " << e.what() << endl;
REQUIRE(false);
}
}
static void ThumbnailTest()
{
cout << "ThumbnailTest" << endl;
CreateTestDirectory();
try
{
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
REQUIRE(audioDir != nullptr);
if (fs::exists(testDir))
{
fs::remove_all(testDir); // Remove any existing database file
}
fs::create_directories(testDir);
// Copy test files to the test directory
for (const auto &entry : fs::directory_iterator(sourceDirectory))
{
if (entry.is_regular_file())
{
fs::copy(entry.path(), testDir / entry.path().filename());
}
}
auto files = audioDir->GetFiles();
REQUIRE(files.size() == 3);
for (const auto &file : files)
{
ThumbnailTemporaryFile thumbnail = audioDir->GetThumbnail(file.fileName(), 200, 200);
REQUIRE(fs::exists(thumbnail.Path()));
cout << "Thumbnail for " << file.fileName() << ": " << thumbnail.Path() << endl;
cout << " " << file.fileName() << "(200,200) size: " << fs::file_size(thumbnail.Path()) << endl;
thumbnail = audioDir->GetThumbnail(file.fileName(), 0, 0);
REQUIRE(fs::exists(thumbnail.Path()));
cout << "Thumbnail for " << file.fileName() << ": " << thumbnail.Path() << endl;
cout << " " << file.fileName() << "(0,0) size: " << fs::file_size(thumbnail.Path()) << endl;
}
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2 * 2); // 3 files, one file is missing a thumbnail, 2 per file.
{
ThumbnailTemporaryFile thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2 * 2); // 3 files, one file is missing a thumbnail, 2 per file.
}
fs::remove(testDir / "Track2.mp3");
files = audioDir->GetFiles();
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2);
// update the last write time, should trigger a thumbnail update.
REQUIRE(GetFile(files, "Track1.mp3")->thumbnailType() == ThumbnailType::Embedded);
fs::copy(sourceDirectory / "Track1.mp3", testDir / "Track1.mp3", fs::copy_options::overwrite_existing);
files = audioDir->GetFiles();
REQUIRE(GetFile(files, "Track1.mp3")->thumbnailType() == ThumbnailType::Unknown);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 0);
audioDir->GetThumbnail("Track1.mp3", 200, 200);
REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1);
CheckForLeakedTemporaryFiles();
cout << endl;
}
catch (const std::exception &e)
{
cout << "Exception: " << e.what() << endl;
REQUIRE(false);
}
}
void ExecutionTimeTest()
{
cout << "Timing test" << endl;
CreateTestDirectory();
// deploy sample files.
fs::create_directories(testDir);
// Copy test files to the test directory
for (const auto &entry : fs::directory_iterator(sourceDirectory))
{
if (entry.is_regular_file())
{
fs::copy(entry.path(), testDir / entry.path().filename());
}
}
AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string());
audioDir->GetFiles();
{
using clock_t = std::chrono::high_resolution_clock;
{
auto start = clock_t::now();
audioDir->GetThumbnail("Track1.mp3",200,200);
auto duration = clock_t::now() - start;
cout << "Track1.mp3(200,200): " << std::chrono::duration_cast<std::chrono::milliseconds>(duration).count() <<"ms" << endl;
}
{
auto start = clock_t::now();
audioDir->GetThumbnail("Track1.mp3",200,200);
auto duration = clock_t::now() - start;
cout << "Track1.mp3(200,200): " << std::chrono::duration_cast<std::chrono::milliseconds>(duration).count() <<"ms" << endl;
}
}
}
TEST_CASE("AudioFiles test", "[AudioFiles]")
{
IndexTest();
ThumbnailTest();
ExecutionTimeTest();
}
// from AudioFileMetadata.cpp
std::string shell_escape_filename(const std::string &filename);
static void ScanThumbnails()
{
const std::string homeDir = getenv("HOME");
std::filesystem::path musicDir = std::filesystem::path(homeDir) / "Music";
for (const auto &entry : std::filesystem::recursive_directory_iterator(musicDir))
{
if (entry.is_directory())
{
continue; // Skip directories
}
if (entry.path().extension() == ".mp3" || entry.path().extension() == ".flac")
{
try
{
// ffprobe -i ~/tmp/test.flac -show_streams -show_entries stream=index,codec_name,disposition
std::stringstream args;
args << "-log_level error -i " << shell_escape_filename(entry.path())
<< " -show_streams -of json -show_entries stream=index,codec_name,disposition 2>/dev/null";
std::string sargs = args.str();
auto output = sysExecForOutput("/usr/bin/ffprobe", sargs, true);
if (output.exitCode != 0)
{
cout << "ffprobe failed for file: " << entry.path() << endl;
continue;
}
std::stringstream ss(output.output);
json_reader reader(ss);
json_variant vt;
reader.read(&vt);
auto streams = vt.as_object()->at("streams").as_array();
struct StreamInfo
{
int index;
std::string codec_name;
std::string comment;
};
std::vector<StreamInfo> thumbnailStreams;
for (const auto &stream : *streams)
{
auto streamObj = stream.as_object();
int index = streamObj->at("index").as_int64();
std::string codecName = streamObj->at("codec_name").as_string();
std::string comment;
if (streamObj->contains("tags"))
{
auto &tagsObj = streamObj->at("tags");
auto &tags = tagsObj.as_object();
if (tags->contains("comment"))
{
comment = tags->at("comment").as_string();
}
}
thumbnailStreams.push_back({index, codecName, comment});
}
if (thumbnailStreams.size() > 2)
{
cout << "File: " << entry.path() << endl;
for (const auto &stream : thumbnailStreams)
{
cout << " Stream Index: " << stream.index
<< ", Codec: " << stream.codec_name
<< ", Comment: " << stream.comment << endl;
}
}
}
catch (const std::exception &e)
{
cout << "Error processing file: " << entry.path() << " - " << e.what() << endl;
}
}
}
}
class TestTimer {
public:
using clock_t = std::chrono::high_resolution_clock;
void Start()
{
startTime = clock_t::now();
}
uint64_t Stop()
{
auto elapsed = clock_t::now()-startTime;
elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
return elapsedMs;
}
double ElapsedMs()
{
return elapsedMs;
}
private:
clock_t::time_point startTime;
uint64_t elapsedMs = 0;
};
static bool isAudioExtension(const std::string &extension)
{
return MimeTypes::instance().AudioExtensions().contains(extension);
}
static bool ContainsMusic(const std::filesystem::path&path)
{
for (auto dirEntry: fs::directory_iterator(path)) {
if (fs::is_regular_file(dirEntry.path())) {
if (isAudioExtension(dirEntry.path().extension())) {
return true;
}
}
}
return false;
}
void ProfileMusicDirectory() {
const std::string homeDir = getenv("HOME");
std::filesystem::path musicDir = std::filesystem::path(homeDir) / "Music";
for (const auto &entry : std::filesystem::recursive_directory_iterator(musicDir))
{
if (!entry.is_directory())
{
continue; // Skip directories
}
if (!ContainsMusic(entry.path())) {
continue;
}
fs::remove(textIndexPath);
AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path());
fs::create_directories(textIndexPath.parent_path());
directoryInfo->TestSetIndexPath(textIndexPath);
uint64_t firstTime;
uint64_t dbIndexSize;
uint64_t entries;
{
TestTimer t;
t.Start();
AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path());
directoryInfo->TestSetIndexPath(textIndexPath);
entries = directoryInfo->GetFiles().size();
firstTime = t.Stop();
dbIndexSize = fs::file_size(textIndexPath);
}
uint64_t secondTime;
{
TestTimer t;
t.Start();
AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path());
directoryInfo->TestSetIndexPath(textIndexPath);
directoryInfo->GetFiles();
secondTime = t.Stop();
}
cout << entry.path().string() << endl;
cout << " " << "first time: " << firstTime << " second time: " << secondTime << endl;
cout << " " << "size: " << dbIndexSize << " entries: " << entries << endl;
}
}
TEST_CASE("Search for audio files with multple thumbnails", "[ScanThumbnails]")
{
AudioDirectoryInfo::SetTemporaryDirectory(tempDir);
AudioDirectoryInfo::SetResourceDirectory(resourceDir);
ScanThumbnails(); // scan all files in music directory.
ProfileMusicDirectory(); // Scans all folders in music directory.
REQUIRE(true); // Just to ensure the test runs without failure
}
TEST_CASE("Thumbnail performance test", "[ProfileThumbnails]" )
{
AudioDirectoryInfo::SetTemporaryDirectory(tempDir);
AudioDirectoryInfo::SetResourceDirectory(resourceDir);
ProfileMusicDirectory(); // Scans all folders in music directory.
REQUIRE(true); // Just to ensure the test runs without failure
}
+11 -5
View File
@@ -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
+1
View File
@@ -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)
+9
View File
@@ -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<AudioFileMetadata> 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<AudioFileMetadata> metadata_;
DECLARE_JSON_MAP(FileEntry);
+22 -3
View File
@@ -58,19 +58,38 @@ static std::string toLower(const std::string&value)
return result;
}
const std::set<std::string> playListExtensions = {
".m3u",
".m3u8",
".pls",
".wpl"
};
const std::set<std::string> 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/"))
{
+2
View File
@@ -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.
// :-(
+121 -51
View File
@@ -37,6 +37,7 @@
#include <set>
#include <MimeTypes.hpp>
#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<std::string> 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<std::string> 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<AudioFileMetadata>(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<std::string>&result, const std::filesystem::path &path)
static void GetAllExtensions(std::set<std::string> &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<std::string>&result, const std::filesystem
static std::set<std::string> GetAllExtensions(const std::filesystem::path &path)
{
std::set<std::string> 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<std::string> 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<FilePropertyDirectoryTree>(
@@ -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()
+1
View File
@@ -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);
+25 -3
View File
@@ -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;
}
+11 -4
View File
@@ -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;
};
}
+1 -1
View File
@@ -738,7 +738,7 @@ namespace pipedal
virtual void setBodyFile(std::shared_ptr<TemporaryFile>&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 {
+156 -35
View File
@@ -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 &param)
{
if (param.empty())
{
return 0;
}
return static_cast<int32_t > (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
{
+13 -1
View File
@@ -45,6 +45,7 @@
#include <signal.h>
#include <semaphore.h>
#include "SchedulerPriority.hpp"
#include "AudioFiles.hpp"
#include <systemd/sd-daemon.h>
@@ -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<WebServer> server;
try
-2
View File
@@ -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
+1
View File
@@ -15,6 +15,7 @@
BODY {
background: #D0D0D0;
}
</style>
<style id="bgStyle">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+4 -1
View File
@@ -39,4 +39,7 @@ input[type="number"].scrollMod::-webkit-inner-spin-button:active {
input[type=number] {
-moz-appearance: textfield;
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
* 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.
*/
import { PiPedalModel } from "./PiPedalModel";
export class ThumbnailType {
static Unknown = 0;
static Embedded = 1; // embedded in the file
static Folder = 2; // from a albumArt.jpg or similar
static None = 3; // Use default thumbnail.
};
export default class AudioFileMetadata {
//AudioFileMetadata&operator=(const AudioFileMetadata&) = default;
deserialize(o: any) {
this.fileName = o.fileName;
this.lastModified = o.lastModified;
this.title = o.title;
this.track = o.track;
this.album = o.album;
this.duration = o.duration;
this.thumbnailType = o.thumbnailType;
this.thumbnailFile = o.thumbnailFile;
this.thumbnailLastModified = o.thumbnailLastModified;
return this;
}
fileName: string = "";
lastModified: number = 0;
title: string = "";
track: number = 0;
album: string = "";
duration: number = 0;
thumbnailType: ThumbnailType = new ThumbnailType();
thumbnailFile = "";
thumbnailLastModified = 0;
}
export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata | undefined, path: string): string {
let coverArtUri: string;
if (!metadata) {
return "/img/missing_thumbnail.jpg";
}
if (metadata.thumbnailType === ThumbnailType.None) {
return "/img/missing_thumbnail.jpg";
} else {
coverArtUri = model.varServerUrl + "Thumbnail"
+ "?path=" + encodeURIComponent(path)
+ "&t=" + metadata.thumbnailLastModified
+ "&w=240&h=240"
;
return coverArtUri;
}
}
+155
View File
@@ -0,0 +1,155 @@
/*
* 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.
*/
import React, { useEffect } from 'react';
import ButtonBase, { ButtonBaseProps } from "@mui/material/ButtonBase";
export interface DraggableButtonBaseProps extends ButtonBaseProps {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onLongPressStart?: (currentTarget: HTMLButtonElement, e: React.PointerEvent<HTMLButtonElement>) => void;
onLongPressMove?: (e: React.PointerEvent<HTMLButtonElement>) => void;
onLongPressEnd?: (e: React.PointerEvent<HTMLButtonElement>) => void;
longPressDelay? : number;
}
interface Point {
x: number;
y: number;
}
function screenToClient(element: HTMLElement, point: Point): Point {
const dpr = (window.devicePixelRatio || 1) as number;;
let rect = element.getBoundingClientRect();
const cssScreenX = point.x / dpr;
const cssScreenY = point.y / dpr;
const cssWindowX = window.screenX / dpr;
const cssWindowY = window.screenY / dpr;
const clientX = cssScreenX - cssWindowX - rect.left;
const clientY = cssScreenY - cssWindowY - rect.top;
return { x: clientX, y: clientY };
}
export default function DraggableButtonBase(props: DraggableButtonBaseProps) {
// Ensure that the props are spread correctly
const { onClick, onLongPressStart, onLongPressEnd,longPressDelay, ...rest } = props;
let [hTimeout, setHTimeout] = React.useState<number | null>(null);
let [pointerId, setPointerId] = React.useState<number | null>(null);
let [longPressed, setLongPressed] = React.useState<boolean>(false);
let [suppressClick, setSuppressClick] = React.useState<boolean>(false);
let [pointerDownPoint, setPointerDownPoint] = React.useState<Point >({x: 0, y: 0});
function cancelLongPress() {
if (hTimeout !== null) {
window.clearTimeout(hTimeout);
setHTimeout(null);
}
setPointerId(null);
setLongPressed(false);
setSuppressClick(false);
}
useEffect(() => {
return () => {
cancelLongPress();
};
}, []);
return (
<ButtonBase
{...rest}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setPointerId(e.pointerId);
setPointerDownPoint(screenToClient(e.currentTarget,{ x: e.screenX, y: e.screenY }));
let currentTarget = e.currentTarget as HTMLButtonElement;
if (longPressDelay === undefined || longPressDelay >= 0)
{
setHTimeout(window.setTimeout(() => {
if (props.onLongPressStart) {
props.onLongPressStart(currentTarget,e);
}
setLongPressed(true);
}, longPressDelay??1250));
}
}}
onPointerMove={(e) => {
if (e.pointerId === pointerId) {
if(longPressed) {
if (props.onLongPressMove) {
props.onLongPressMove(e);
}
} else {
let clientPoint = screenToClient(e.currentTarget as HTMLElement, {x: e.screenX, y: e.screenY} );
let dx = pointerDownPoint.x- clientPoint.x;
let dy = pointerDownPoint.y - clientPoint.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
cancelLongPress();
}
}
}
}}
onPointerUp={(e) => {
if (e.pointerId === pointerId) {
e.currentTarget.releasePointerCapture(e.pointerId);
setPointerId(null);
if (longPressed) {
if (props.onLongPressEnd) {
props.onLongPressEnd(e);
}
}
cancelLongPress();
if (e.isPropagationStopped()) {
setSuppressClick(true); // only way to cancel the click
return;
}
}
}}
onClick={(e) => {
if (suppressClick) {
e.stopPropagation();
e.preventDefault();
setSuppressClick(false);
} else {
if (props.onClick) {
props.onClick(e);
}
}
}}
onPointerCancelCapture={(e) => {
if (pointerId !== null) {
e.currentTarget.releasePointerCapture(e.pointerId);
if (longPressed && props.onLongPressEnd) {
props.onLongPressEnd(e)
}
cancelLongPress();
}
}}>
{props.children}
</ButtonBase>
);
}
+530 -161
View File
@@ -21,7 +21,8 @@
import React from 'react';
import { createStyles } from './WithStyles';
import DraggableButtonBase from './DraggableButtonBase';
import CloseIcon from '@mui/icons-material/Close';
import { Theme } from '@mui/material/styles';
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import RenameDialog from './RenameDialog';
@@ -47,19 +48,24 @@ import OldDeleteIcon from './OldDeleteIcon';
import Toolbar from '@mui/material/Toolbar';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import CircularProgress from '@mui/material/CircularProgress';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { UiFileProperty } from './Lv2Plugin';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import DialogEx from './DialogEx';
import UploadFileDialog from './UploadFileDialog';
import OkCancelDialog from './OkCancelDialog';
import HomeIcon from '@mui/icons-material/Home';
import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog';
import { getAlbumArtUri } from './AudioFileMetadata';
interface Point {
x: number;
y: number;
}
const styles = (theme: Theme) => createStyles({
@@ -85,6 +91,20 @@ const audioFileExtensions: { [name: string]: boolean } = {
".ra": true
};
function screenToClient(element: HTMLDivElement, point: Point): Point {
const dpr = (window.devicePixelRatio || 1) as number;;
let rect = element.getBoundingClientRect();
const cssScreenX = point.x / dpr;
const cssScreenY = point.y / dpr;
const cssWindowX = window.screenX / dpr;
const cssWindowY = window.screenY / dpr;
const clientX = cssScreenX - cssWindowX - rect.left;
const clientY = cssScreenY - cssWindowY - rect.top;
return { x: clientX, y: clientY };
}
function isAudioFile(filename: string) {
let npos = filename.lastIndexOf('.');
let nposSlash = filename.lastIndexOf('/');
@@ -105,6 +125,10 @@ export interface FilePropertyDialogProps extends WithStyles<typeof styles> {
onCancel: () => void
};
export interface FilePropertyDialogState {
reordering: boolean;
loading: boolean;
dragState: DragState | null;
showProgress: boolean;
fullScreen: boolean;
selectedFile: string;
selectedFileIsDirectory: boolean;
@@ -115,7 +139,7 @@ export interface FilePropertyDialogState {
fileResult: FileRequestResult;
navDirectory: string;
windowWidth: number;
uploadDirectory: string;
currentDirectory: string;
isProtectedDirectory: boolean;
columns: number;
columnWidth: number;
@@ -128,6 +152,12 @@ export interface FilePropertyDialogState {
initialSelection: string;
};
class DragState {
activeItem: string = "";
height: number = 0;
from: number = 0;
to: number = 0;
};
function pathExtension(path: string) {
let dotPos = path.lastIndexOf('.');
if (dotPos === -1) return "";
@@ -193,6 +223,9 @@ export default withStyles(
return pathParentDirectory(selectedFile);
}
isTracksDirectory(): boolean {
return this.state.currentDirectory.startsWith("/var/pipedal/audio_uploads/shared/audio/Tracks");
}
constructor(props: FilePropertyDialogProps) {
super(props);
@@ -201,13 +234,17 @@ export default withStyles(
let selectedFile = props.selectedFile;
this.state = {
reordering: false,
loading: false,
dragState: null,
showProgress: false,
fullScreen: this.getFullScreen(),
selectedFile: selectedFile,
selectedFileProtected: true,
windowWidth: this.windowSize.width,
selectedFileIsDirectory: false,
navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty),
uploadDirectory: "",
currentDirectory: "",
hasSelection: false,
hasFileSelection: false,
canDelete: false,
@@ -229,8 +266,17 @@ export default withStyles(
return window.innerWidth < 450 || window.innerHeight < 450;
}
hProgressTimeout: number | null = null;
private scrollRef: HTMLDivElement | null = null;
cancelProgressTimeout() {
if (this.hProgressTimeout !== null) {
window.clearTimeout(this.hProgressTimeout);
this.hProgressTimeout = null;
}
}
onScrollRef(element: HTMLDivElement | null) {
this.scrollRef = element;
this.maybeScrollIntoView();
@@ -254,36 +300,52 @@ export default withStyles(
if (this.props.fileProperty.directory === "") {
return;
}
this.setState({
loading: true,
showProgress: false,
fileResult: new FileRequestResult(),
hasFileSelection: false,
hasSelection: false,
});
this.hProgressTimeout = window.setTimeout(() => {
if (this.mounted) {
this.setState({ showProgress: true });
}
}, 1000);
this.model.requestFileList2(navPath, this.props.fileProperty)
.then((filesResult) => {
if (this.mounted) {
// let insertionPoint = files.length;
// for (let i = 0; i < files.length; ++i) {
// if (!files[i].isDirectory) {
// insertionPoint = i;
// break;
// }
// }
this.cancelProgressTimeout();
filesResult.files.splice(0, 0, { pathname: "", displayName: "<none>", isDirectory: false, isProtected: true });
let fileEntry = this.getFileEntry(filesResult.files, this.state.selectedFile);
let fileEntry = this.getFileEntry(filesResult.files, this.state.selectedFile);
this.setState({
loading: false,
showProgress: false,
isProtectedDirectory: filesResult.isProtected,
fileResult: filesResult,
hasSelection: !!fileEntry,
hasFileSelection: !!fileEntry && (!fileEntry.isDirectory) && (!fileEntry.isProtected),
selectedFileProtected: fileEntry ? fileEntry.isProtected : true,
navDirectory: navPath,
uploadDirectory: filesResult.currentDirectory
currentDirectory: filesResult.currentDirectory
});
}
}).catch((error) => {
this.cancelProgressTimeout();
if (!this.mounted) return;
if (navPath !== "") // deleted sample directory maybe?
{
this.navigate("");
} else {
this.setState({
loading: false,
showProgress: false,
fileResult: new FileRequestResult(),
isProtectedDirectory: false,
navDirectory: "",
currentDirectory: ""
});
this.model.showAlert(error.toString())
}
});
@@ -291,6 +353,136 @@ export default withStyles(
private lastDivRef: HTMLDivElement | null = null;
longPressStartPoint: Point | null = null;
dragFromPosition: number = -1;
dragToPosition: number = -1;
maxPosition: number = 0;
handleLongPressStart(currentTarget: HTMLButtonElement, e: React.PointerEvent<HTMLButtonElement>) {
if (!this.isTracksDirectory()) {
return;
}
this.dragFromPosition = parseInt(currentTarget.getAttribute("data-position") as string, 10);
this.dragToPosition = this.dragToPosition;
let element = currentTarget;
element.style.position = "relative";
element.style.zIndex = "1000";
element.style.top = "5px";
element.style.left = "5px";
element.style.background = isDarkMode() ? "#555": "#EEF" // xxx: dark mode.
if (this.lastDivRef) {
this.longPressStartPoint = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY });
}
}
handleLongPressMove(e: React.PointerEvent<HTMLButtonElement>) {
if (!this.isTracksDirectory()) {
return;
}
if (this.lastDivRef) {
let element = e.target as HTMLButtonElement;
let point = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY });
if (this.longPressStartPoint) {
let dy = point.y - this.longPressStartPoint.y;
element.style.left = (5) + "px";
element.style.top = (5 + dy) + "px";
let height = element.clientHeight;
let positionChange_: number = 0;
if (dy > 0) {
positionChange_ = Math.floor((dy + height / 2) / height);
} else {
positionChange_ = Math.ceil((dy - height / 2) / height)
}
let toPosition = this.dragFromPosition + positionChange_;
if (toPosition < 0) {
toPosition = 0;
}
if (toPosition > this.maxPosition) {
toPosition = this.maxPosition;
}
if (toPosition !== this.dragToPosition) {
this.dragToPosition = toPosition;
this.setState({
dragState: {
activeItem: element.getAttribute("data-pathname") || "",
height: height,
from: this.dragFromPosition,
to: toPosition
}
});
}
}
}
}
handleReorderFiles(from: number, to: number) {
// Update the local state with the new order so that we don't
// flash the old order while waiting for a server update.
let oldFileResult = this.state.fileResult;
let newFileResult = new FileRequestResult();
newFileResult.files = oldFileResult.files.slice();
newFileResult.isProtected = oldFileResult.isProtected;
newFileResult.currentDirectory = oldFileResult.currentDirectory;
newFileResult.breadcrumbs = oldFileResult.breadcrumbs.slice();
let ixFrom = -1;
let ixTo = -1;
let ix = 0;
for (let i = 0; i < newFileResult.files.length; ++i) {
let fileEntry = newFileResult.files[i];
if (fileEntry.metadata) {
if (ix === from) {
ixFrom = i;
}
if (ix === to) {
ixTo = i;
}
++ix;
}
}
if (ixTo === -1 || ixFrom === -1) {
return;
}
if (ixTo == ixFrom) return;
if (ixTo < ixFrom) {
// move up.
newFileResult.files.splice(ixTo, 0, newFileResult.files[ixFrom]);
newFileResult.files.splice(ixFrom + 1, 1);
} else {
// move down.
newFileResult.files.splice(ixTo+1,0,newFileResult.files[ixFrom]);
newFileResult.files.splice(ixFrom, 1);
}
this.setState({ fileResult: newFileResult ,dragState: null});
// Now send the reorder request to the server.
this.model.reorderAudioFiles(
this.state.currentDirectory, from, to);
}
handleLongPressEnd(e: React.PointerEvent<HTMLButtonElement>) {
if (!this.isTracksDirectory()) {
return;
}
let dragState = this.state.dragState;
if (dragState && dragState.to != dragState.from) {
this.handleReorderFiles(dragState.from, dragState.to);
}
// prevent onClick from firing.
e.preventDefault();
e.stopPropagation();
let element = e.currentTarget as HTMLButtonElement;
element.style.position = "";
element.style.zIndex = "";
element.style.top = "";
element.style.left = "";
element.style.background = ""; // xxx: dark mode.
this.setState({ dragState: null });
}
onMeasureRef(div: HTMLDivElement | null) {
this.lastDivRef = div;
if (div) {
@@ -324,13 +516,16 @@ export default withStyles(
this.requestScroll = true;
}
componentWillUnmount() {
this.cancelProgressTimeout();
super.componentWillUnmount();
this.mounted = false;
this.lastDivRef = null;
}
componentDidUpdate(prevProps: Readonly<FilePropertyDialogProps>, prevState: Readonly<FilePropertyDialogState>, snapshot?: any): void {
super.componentDidUpdate?.(prevProps, prevState, snapshot);
if (prevProps.open !== this.props.open || prevProps.fileProperty !== this.props.fileProperty || prevProps.selectedFile !== this.props.selectedFile) {
if (prevProps.open !== this.props.open
) {
if (this.props.open) {
let selectedFile = this.props.selectedFile;
let navDirectory = this.getNavDirectoryFromFile(selectedFile, this.props.fileProperty);
@@ -382,10 +577,12 @@ export default withStyles(
}
onSelectValue(fileEntry: FileEntry) {
if (this.state.reordering) {
return;
}
this.requestScroll = true;
if (!fileEntry.isDirectory)
{
this.props.onApply(this.props.fileProperty,fileEntry.pathname);
if (!fileEntry.isDirectory) {
this.props.onApply(this.props.fileProperty, fileEntry.pathname);
}
this.setState({
selectedFile: fileEntry.pathname,
@@ -396,6 +593,9 @@ export default withStyles(
})
}
onDoubleClickValue(selectedFile: string) {
if (this.state.reordering) {
return;
}
this.openSelectedFile();
}
@@ -478,7 +678,24 @@ export default withStyles(
navigate(relativeDirectory: string) {
this.requestFiles(relativeDirectory);
this.setState({ navDirectory: relativeDirectory });
}
getTrackTitle(fileEntry: FileEntry): string {
if (!fileEntry.metadata) {
return fileEntry.displayName;
}
let metadata = fileEntry.metadata;
let trackDisplay = "";
if (metadata.track > 0) {
if (metadata.track >= 1000) {
trackDisplay = (metadata.track % 1000).toString() + "/" + Math.floor(metadata.track / 1000) + ". ";
} else {
trackDisplay = metadata.track.toString() + ". ";
}
}
return trackDisplay + metadata.title;
}
getTrackThumbnail(fileEntry: FileEntry): string {
return getAlbumArtUri(this.model, fileEntry.metadata, fileEntry.pathname);
}
renderBreadcrumbs() {
let breadcrumbs: React.ReactElement[] = [(
@@ -592,6 +809,8 @@ export default withStyles(
}
render() {
const isTracksDirectory = this.isTracksDirectory();
const classes = withStyles.getClasses(this.props);
let columnWidth = this.state.columnWidth;
let okButtonText = "Select";
@@ -603,17 +822,29 @@ export default withStyles(
if (this.state.hasSelection) {
protectedItem = this.state.selectedFileProtected;
}
let canMoveOrRename = this.hasSelectedFileOrFolder() && !protectedItem;
let canMove = this.hasSelectedFileOrFolder() && !protectedItem;
let canRename = this.hasSelectedFileOrFolder() && !protectedItem && !isTracksDirectory;
let canReorder = isTracksDirectory;
let needsDivider = canMove || canRename || canReorder;
let trackPosition = 0;
return this.props.open &&
(
<DialogEx
fullScreen={this.state.fullScreen}
onClose={() => {
this.props.onCancel();
if (this.state.reordering) {
this.setState({ reordering: false, dragState: null });
} else {
this.props.onCancel();
}
}}
onEnterKey={() => {
this.openSelectedFile();
if (this.state.reordering) {
this.setState({ reordering: false, dragState: null });
} else {
this.openSelectedFile();
}
}}
open={this.props.open} tag="fileProperty"
fullWidth maxWidth="md"
@@ -628,86 +859,150 @@ export default withStyles(
}
}}
>
<DialogTitle style={{ paddingBottom: 0 }} >
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { this.props.onCancel(); }}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
{this.props.fileProperty.label}
</Typography>
<DialogTitle style={{
paddingBottom: 0,
background: this.state.reordering ?
(isDarkMode() ? "#523" : "#EDF")
: undefined,
marginBottom: 16, paddingTop: 0
}} >
{this.state.reordering ? (
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="cancel"
style={{ opacity: 0.6 }}
onClick={() => { this.setState({ reordering: false, dragState: null }); }
}
>
<CloseIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
Reorder files
</Typography>
</Toolbar>
<IconButton
aria-label="new folder"
edge="end"
color="inherit"
style={{ opacity: 0.6, marginRight: 8 }}
onClick={(ev) => { this.onNewFolder(); }}
disabled={protectedDirectory}
>
<CreateNewFolderIcon />
</IconButton>
) : (
<>
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { this.props.onCancel(); }}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
{this.props.fileProperty.label}
</Typography>
<IconButton
aria-label="new folder"
edge="end"
color="inherit"
style={{ opacity: 0.6, marginRight: 8 }}
onClick={(ev) => { this.onNewFolder(); }}
disabled={protectedDirectory}
>
<CreateNewFolderIcon />
</IconButton>
<IconButton
aria-label="display more actions"
edge="end"
color="inherit"
style={{ opacity: 0.6 }}
onClick={(ev) => { this.handleMenuOpen(ev); }}
disabled={protectedDirectory}
<IconButton
aria-label="display more actions"
edge="end"
color="inherit"
style={{ opacity: 0.6 }}
onClick={(ev) => { this.handleMenuOpen(ev); }}
disabled={protectedDirectory}
>
<MoreIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={this.state.menuAnchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={this.state.menuAnchorEl !== null}
onClose={() => { this.handleMenuClose(); }}
>
<MenuItem onClick={() => { this.handleMenuClose(); this.onNewFolder(); }}>New folder</MenuItem>
{(needsDivider) && (<Divider />)}
{canMove && (<MenuItem onClick={() => { this.handleMenuClose(); this.onMove(); }}>Move</MenuItem>)}
{canRename && this.hasSelectedFileOrFolder() && !protectedItem && (<MenuItem onClick={() => {
this.handleMenuClose(); this.onRename();
}}>Rename</MenuItem>)}
{canReorder && (
<MenuItem onClick={() => { this.handleMenuClose(); this.onReorder(); }}>Reorder</MenuItem>)}
</Menu>
</Toolbar>
<div style={{ flex: "0 0 auto", marginLeft: 0, marginRight: 0, overflowX: "clip" }}>
{this.renderBreadcrumbs()}
</div>
</>
)}
>
<MoreIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={this.state.menuAnchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={this.state.menuAnchorEl !== null}
onClose={() => { this.handleMenuClose(); }}
>
<MenuItem onClick={() => { this.handleMenuClose(); this.onNewFolder(); }}>New folder</MenuItem>
{canMoveOrRename && (<Divider />)}
{canMoveOrRename && (<MenuItem onClick={() => { this.handleMenuClose(); this.onMove(); }}>Move</MenuItem>)}
{canMoveOrRename && this.hasSelectedFileOrFolder() && !protectedItem && (<MenuItem onClick={() => { this.handleMenuClose(); this.onRename(); }}>Rename</MenuItem>)}
</Menu>
</Toolbar>
<div style={{ flex: "0 0 auto", marginLeft: 0, marginRight: 0, overflowX: "clip" }}>
{this.renderBreadcrumbs()}
</div>
</DialogTitle>
<DialogContent style={{ paddingLeft: 0, paddingRight: 0, paddingTop: 0, colorScheme: (isDarkMode() ? "dark" : "light") }}>
<DialogContent style={{
paddingLeft: 0, paddingRight: 0, paddingTop: 0, colorScheme: (isDarkMode() ? "dark" : "light"),
position: "relative"
}}>
<div style={{
paddingLeft: 16, paddingRight: 16, paddingTop: 8, paddingBottom: 8,
display: this.state.loading ? "flex" : "none", flexFlow: "column nowrap",
justifyContent: "stretch", alignItems: "center",
position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 10
}}
>
{this.state.showProgress && (
<>
<div style={{ flex: "1 1 1px" }} />
<CircularProgress />
<div style={{ flex: "2 2 1px" }} />
</>
)}
</div>
<div
ref={(element) => this.onMeasureRef(element)}
style={{
flex: "1 1 100%", display: "flex", flexFlow: "row wrap",
justifyContent: "flex-start", alignContent: "flex-start", paddingLeft: 16, paddingBottom: 16
position: "relative", justifyContent: "flex-start", alignContent: "flex-start", paddingLeft: 16, paddingBottom: 16
}}>
{
(this.state.columns !== 0) && // don't render until we have number of columns derived from layout.
this.state.fileResult.files.map(
(value: FileEntry, index: number) => {
if (this.state.reordering && !value.metadata) {
return null; // don't render non-track files when reordering.
}
let displayValue = value.displayName;
if (displayValue === "") {
displayValue = "<none>";
}
let selected = value.pathname === this.state.selectedFile;
let dataPosition = "";
let myTrackPosition = trackPosition;
if (value.metadata && value.metadata.track) {
dataPosition = trackPosition.toString();
++trackPosition;
this.maxPosition = trackPosition;
}
let selected = value.pathname === this.state.selectedFile && !this.state.reordering;
let selectBg = selected ? "rgba(0,0,0,0.15)" : "rgba(0,0,0,0.0)";
if (isDarkMode()) {
selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)";
@@ -716,105 +1011,176 @@ export default withStyles(
if (selected) {
scrollRef = (element) => { this.onScrollRef(element) };
}
let dragOffset = 0;
let dragState = this.state.dragState;
if (dragState && value.metadata) {
if (myTrackPosition !== dragState.from) {
if (myTrackPosition >= dragState.to && myTrackPosition < dragState.from) {
dragOffset = dragState.height;
} else if (myTrackPosition > dragState.from && myTrackPosition <= dragState.to) {
dragOffset = -dragState.height;
}
}
}
return (
<ButtonBase key={value.pathname}
<DraggableButtonBase key={value.pathname}
longPressDelay={this.state.reordering ? 0 : -1}
data-position={dataPosition}
data-fileName={
value.metadata ? value.metadata.fileName : null
}
style={{ width: columnWidth, flex: "0 0 auto", height: 48, position: "relative" }}
style={{
width: columnWidth, flex: "0 0 auto", height: value.metadata ? 64 : 48,
position: "relative",
top: dragOffset + "px",
}}
onClick={() => this.onSelectValue(value)}
onDoubleClick={() => { this.onDoubleClickValue(value.pathname); }}
onLongPressEnd={(e) => {
this.handleLongPressEnd(e);
}
}
onLongPressStart={(currentTarget, e) => {
this.handleLongPressStart(currentTarget, e);
}
}
onLongPressMove={(e) => {
this.handleLongPressMove(e);
}
}
>
<div ref={scrollRef} style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
{this.getIcon(value)}
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
</div>
</ButtonBase>
{value.metadata ?
(
<div style={{
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
}}>
<img
onDragStart={(e) => { e.preventDefault(); }}
src={this.getTrackThumbnail(value)}
style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} />
<div style={{
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
marginLeft: 8,
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
}}>
<Typography noWrap
className={classes.secondaryText}
variant="body2"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left",marginBottom: 4 }}>
{this.getTrackTitle(value)}
</Typography>
<Typography noWrap className={classes.secondaryText}
variant="body2"
color="textSecondary"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", fontSize: "0.95em" }}>
{value.metadata?.album ?? "#error"}
</Typography>
</div>
</div>
) : (
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
{this.getIcon(value)}
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
</div>
)}
</DraggableButtonBase>
);
}
)
}
</div>
</DialogContent>
<Divider />
<DialogActions style={{ justifyContent: "stretch" }}>
{this.state.windowWidth > 500 ? (
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
disabled={!this.state.hasSelection || this.state.selectedFile === "" || protectedItem}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
{!this.state.reordering && (
<>
<Divider />
<DialogActions style={{ justifyContent: "stretch" }}>
{this.state.windowWidth > 500 ? (
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
disabled={!this.state.hasSelection || this.state.selectedFile === "" || protectedItem}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory}
>
<div>Upload</div>
</Button>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory}
>
<div>Upload</div>
</Button>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileDownloadIcon />}
onClick={() => { this.handleDownloadFile(); }} disabled={protectedDirectory || !this.state.hasFileSelection}
>
<div>Download</div>
</Button>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileDownloadIcon />}
onClick={() => { this.handleDownloadFile(); }} disabled={protectedDirectory || !this.state.hasFileSelection}
>
<div>Download</div>
</Button>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
<Button variant="dialogSecondary" onClick={() => {
this.props.onApply(this.props.fileProperty,this.state.initialSelection);
this.props.onCancel();
}} aria-label="cancel">
Cancel
</Button>
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
<Button variant="dialogSecondary" onClick={() => {
this.props.onApply(this.props.fileProperty, this.state.initialSelection);
this.props.onCancel();
}} aria-label="cancel">
Cancel
</Button>
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
>
{okButtonText}
</Button>
</div>
) : (
<div style={{width: "100%"}}>
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
disabled={!this.state.hasSelection || this.state.selectedFile === "" || protectedItem}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
>
{okButtonText}
</Button>
</div>
) : (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
disabled={!this.state.hasSelection || this.state.selectedFile === "" || protectedItem}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory}
>
<div>Upload</div>
</Button>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory}
>
<div>Upload</div>
</Button>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileDownloadIcon />}
onClick={() => { this.handleDownloadFile(); }} disabled={protectedDirectory || !this.state.hasFileSelection}
>
<div>Download</div>
</Button>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileDownloadIcon />}
onClick={() => { this.handleDownloadFile(); }} disabled={protectedDirectory || !this.state.hasFileSelection}
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
</div>
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
>
<div>Download</div>
</Button>
<Button variant="dialogSecondary" onClick={() => { this.props.onCancel(); }} aria-label="cancel">
Cancel
</Button>
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
</div>
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
>
{okButtonText}
</Button>
</div>
</div>
)}
</DialogActions>
<Button variant="dialogSecondary" onClick={() => { this.props.onCancel(); }} aria-label="cancel">
Cancel
</Button>
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
>
{okButtonText}
</Button>
</div>
</div>
)}
</DialogActions>
</>
)}
<UploadFileDialog
open={this.state.openUploadFileDialog}
onClose=
@@ -824,7 +1190,7 @@ export default withStyles(
}
}
uploadPage={
"uploadUserFile?directory=" + encodeURIComponent(this.state.uploadDirectory)
"uploadUserFile?directory=" + encodeURIComponent(this.state.currentDirectory)
+ "&id=" + this.props.instanceId.toString()
+ "&property=" + encodeURIComponent(this.props.fileProperty.patchProperty)
+ "&ext="
@@ -933,6 +1299,9 @@ export default withStyles(
private onRename(): void {
this.setState({ renameDialogOpen: true });
}
private onReorder(): void {
this.setState({ reordering: true });
}
private onExecuteRename(newName: string) {
let newPath: string = "";
let oldPath: string = "";
@@ -37,6 +37,7 @@ import { isDarkMode } from './DarkMode';
import IconButton from '@mui/material/IconButton';
class DirectoryTree {
name: string = "";
path: string = "";
@@ -279,7 +280,8 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
)
}
</IconButton>
<ButtonBase ref={ref} style={{ flexGrow: 1, flexShrink: 1, position: "relative" }} onClick={() => { this.onTreeClick(directoryTree); }}>
<ButtonBase ref={ref} style={{ flexGrow: 1, flexShrink: 1, position: "relative" }} onClick={() => { this.onTreeClick(directoryTree); }}
>
<div style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center", paddingLeft: 8 }}>
{
@@ -329,8 +331,11 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
<Typography variant="body1">{this.props.dialogTitle}</Typography>
</DialogTitle>
<Divider />
<DialogContent style={{marginLeft: 0, paddingLeft: 0}} >
<div style={{ display: "flex", flexGrow: 1, flexShrink: 1, overflowY: "auto" }} >
<DialogContent style={{marginLeft: 0, paddingLeft: 0,
display: "flex",flexFlow: "column nowrap",
alignItems: "stretch", justifyContent: "stretch"}} >
<div style={{ flex: "1 1 auto", display: "flex", height: "100%",
overflowY: "auto", overflowX: "auto" }} >
{
this.renderTree()
}
+15 -1
View File
@@ -41,7 +41,7 @@ import AlsaDeviceInfo from './AlsaDeviceInfo';
import { AndroidHostInterface, FakeAndroidHost } from './AndroidHost';
import { ColorTheme, getColorScheme, setColorScheme } from './DarkMode';
import FilePropertyDirectoryTree from './FilePropertyDirectoryTree';
import AudioFileMetadata from './AudioFileMetadaa';
import AudioFileMetadata from './AudioFileMetadata';
export enum State {
@@ -87,6 +87,8 @@ export interface FileEntry {
displayName: string;
isDirectory: boolean;
isProtected: boolean;
// optional metadata for audio
metadata?: AudioFileMetadata;
};
export interface BreadcrumbEntry {
@@ -3091,6 +3093,18 @@ export class PiPedalModel //implements PiPedalModel
30 * 1000);
}
reorderAudioFiles(
path: string,
from: number,
to: number
) {
this.webSocket?.send("reorderAudioFiles", {
path: path,
from: from,
to: to
})
}
};
let instance: PiPedalModel | undefined = undefined;
+1 -1
View File
@@ -450,7 +450,7 @@ const PluginControlView =
/>
));
}
private ixKey: number;
private ixKey: number = 1;
makeStandardControl(uiControl: UiControl, controlValues: ControlValue[]): ReactNode {
let symbol = uiControl.symbol;
+21 -20
View File
@@ -40,6 +40,7 @@ import JsonAtom from './JsonAtom';
import { UiFileProperty } from './Lv2Plugin';
import { Divider } from '@mui/material';
import useWindowSize from './UseWindowSize';
import { getAlbumArtUri } from './AudioFileMetadata';
let Player__seek = "http://two-play.com/plugins/toob-player#seek"
const AUDIO_FILE_PROPERTY_URI = "http://two-play.com/plugins/toob-player#audioFile";
@@ -86,15 +87,6 @@ const WallPaper = styled('div')({
},
});
function jsonToPath(json: string) {
try {
let o = JSON.parse(json);
return o.value;
}
catch (_) {
return "";
}
}
interface WidgetProps {
noBorders: boolean;
@@ -192,7 +184,7 @@ export default function ToobPlayerControl(
const HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK = 500;
const useHorizontalLayout = height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK && width > 573 && width > height;
const useVerticalScroll = width < 573;
//const useVerticalScroll = width < 573;
const useQuadMixPanel = width < 720;
const noBorders = width < 420 || height < 720;
@@ -207,22 +199,31 @@ export default function ToobPlayerControl(
}
function onAudioFileChanged(path: string) {
setAudioFile(path);
if (path === "") {
setTitle("");
setAlbum("");
setCoverArt(defaultCoverArt);
return;
}
model.getAudioFileMetadata(path)
.then((metadata) => {
if (metadata.track !== "") {
let track = metadata.track;
if (!track.endsWith('.')) {
track += '.';
let strTrack: string = "";
if (metadata.track > 0) {
let disc = (metadata.track /1000);
if (disc >= 1) {
strTrack = (metadata.track % 1000).toString() + '/' + Math.floor(disc).toString();
} else {
strTrack = metadata.track.toString();
}
setTitle(track + " " + metadata.title);
}
else {
setTitle(metadata.title);
strTrack += ". ";
}
let coverArtUri = getAlbumArtUri(model,metadata,path);
setCoverArt(coverArtUri);
setTitle(strTrack + metadata.title);
setAlbum(metadata.album);
})
.catch(()=>{
setTitle("#error");
.catch((e)=>{
setTitle("#error" + e.message);
setAlbum("");
});