/* * Copyright (c) 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 "AudioFiles.hpp" #include #include #include #include "AudioFileMetadataReader.hpp" #include "Locale.hpp" #include "MimeTypes.hpp" #include #include "AudioFilesDb.hpp" #include "Lv2Log.hpp" #include "ss.hpp" #include "util.hpp" #include #undef _GLIBCXX_DEBUG // Ensure we are not in debug mode, as this file is not compatible with it. #include "SQLiteCpp/SQLiteCpp.h" using namespace pipedal; using namespace pipedal::impl; namespace fs = std::filesystem; static constexpr size_t INVALID_INDEX = std::numeric_limits::max(); // All the varous cover art files I found on my personal music collection. // These are used to find the cover art for a directory. Based on // scanning a large music collection that have been tagged by various tools // including iTunes, Windows Media Playe, and a variety of taggers. // // Files are listed in order of preference. static std::vector COVER_ART_FILES = { "Folder.jpg", // window media player/explorer. "Cover.jpg", // itunes. "folder.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. "cover.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. "Artwork.jpg", // itunes. "Front.jpg", "front.jpg", // linux. "AlbumArt.jpg", "albumArt.jpg", "Frontcover.jpg", "AlbumArtSmall.jpg" // windows media player. }; bool pipedal::isArtworkFileName(const std::string &fileName) { for (const auto &coverArtFile : COVER_ART_FILES) { if (fileName == coverArtFile) { return true; } } return false; } 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() { if (temporaryDirectory.empty()) { return "/tmp/pipedal/audiofiles"; // used during testing. } return temporaryDirectory; } std::filesystem::path AudioDirectoryInfo::GetResourceDirectory() { 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) { auto sctp = std::chrono::time_point_cast( fileTime - fs::file_time_type::clock::now() + std::chrono::system_clock::now()); auto result = std::chrono::duration_cast(sctp.time_since_epoch()).count(); return result; } static int64_t GetLastWriteTime(const fs::path &file) { try { 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::filesystem::path &path, const std::filesystem::path &indexPath) : path(path), indexPath( (indexPath.empty() ? path : indexPath) / ".index.pipedal") { if (this->indexPath.parent_path() != path) { fs::create_directories(this->indexPath.parent_path()); } } virtual ~AudioDirectoryInfoImpl() {} virtual std::vector GetFiles() override; virtual ThumbnailTemporaryFile GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) 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; virtual void MoveAudioFile( const std::string &directory, int32_t fromPosition, int32_t toPosition) override; private: std::vector QueryTracks(); bool opened = false; std::filesystem::path indexPath; void OpenAudioDb(); ThumbnailTemporaryFile GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height); std::shared_ptr audioFilesDb; fs::path path; using id_t = int64_t; std::vector 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::filesystem::path &path, const std::filesystem::path &indexPath) { return std::make_shared(path, indexPath); } std::vector AudioDirectoryInfoImpl::QueryTracks() { if (audioFilesDb) { return audioFilesDb->QueryTracks(); } return std::vector(); } std::vector AudioDirectoryInfoImpl::GetFiles() { OpenAudioDb(); std::vector dbFiles; try { dbFiles = UpdateDbFiles(); } catch (const std::exception &e) { Lv2Log::error("Error updating audio file info: %s - %s", path.string().c_str(), e.what()); } std::vector metadataResults; for (const auto &dbFile : dbFiles) { AudioFileMetadata metadata{dbFile}; // slice copy of just the metadata. metadataResults.push_back(std::move(metadata)); } return metadataResults; } static bool isAudioExtension(const std::string &extension) { return MimeTypes::instance().AudioExtensions().contains(extension); } static bool HasPositionInfo(const std::vector &dbFiles) { for (const auto &file : dbFiles) { if (file.position() != -1) { return true; } } return false; } static int DefaultedSortOrder(int track) { if (track < 0) { return std::numeric_limits::max(); } return track; } static bool isFolderArtwork(const std::string &name) { return name.ends_with(".jpg") || name.ends_with(".png"); } static void SortDbFiles(std::vector &dbFiles, Collator::ptr &collator) { std::sort( dbFiles.begin(), dbFiles.end(), [collator](const DbFileInfo &a, const DbFileInfo &b) { if (isFolderArtwork(a.fileName()) != isFolderArtwork(b.fileName())) { return isFolderArtwork(a.fileName()) < isFolderArtwork(b.fileName()); } if (a.position() != b.position()) { return DefaultedSortOrder(a.position()) < DefaultedSortOrder(b.position()); } if (a.track() != b.track()) { return DefaultedSortOrder(a.track()) < DefaultedSortOrder(b.track()); } if (a.title() != b.title()) { return collator->Compare(a.title(), b.title()) < 0; } if (a.album() != b.album()) { return collator->Compare(a.title(), b.title()) < 0; } return false; ; }); } std::vector AudioDirectoryInfoImpl::UpdateDbFiles() { std::shared_ptr transaction; if (audioFilesDb) { transaction = audioFilesDb->transaction(); } std::vector dbFiles = QueryTracks(); std::vector newFiles; bool updateRequired = false; std::map nameToDbRecord; for (size_t i = 0; i < dbFiles.size(); ++i) { auto *pFile = &(dbFiles[i]); nameToDbRecord[pFile->fileName()] = pFile; } for (auto dirEntry : fs::directory_iterator(path)) { bool isDirectory; try { isDirectory = dirEntry.is_directory(); } catch ( const std::filesystem::filesystem_error &e) { Lv2Log::error("Error accessing directory entry: %s - %s", dirEntry.path().string().c_str(), e.what()); continue; // skip this entry if we cannot access it. } if (!isDirectory) { auto path = dirEntry.path(); std::string name = path.filename(); std::string extension = path.extension(); if (isAudioExtension(extension) || isArtworkFileName(name)) { // If the file is an audio file or a cover art file, we need to check it. if (name.empty() || name[0] == '.') { continue; // skip hidden files. } } else { continue; // not an audio file or cover art file. } { int64_t lastModified = fileTimeToInt64(dirEntry.last_write_time()); auto f = nameToDbRecord.find(name); if (f != nameToDbRecord.end()) { 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; } } } } for (auto i = dbFiles.begin(); i != dbFiles.end(); ++i) { if (!i->present()) { DbDeleteFile(&*i); dbFiles.erase(i); updateRequired = true; --i; } } // 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::None) { 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; } } } } 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. dbFiles.insert(dbFiles.end(), newFiles.begin(), newFiles.end()); SortDbFiles(dbFiles, collator); for (size_t i = 0; i < dbFiles.size(); ++i) { auto newPosition = static_cast(i); if (newPosition != dbFiles[i].position()) { dbFiles[i].dirty(true); } dbFiles[i].position(newPosition); } } for (auto &dbFile : dbFiles) { if (dbFile.dirty()) { if (audioFilesDb) { audioFilesDb->WriteFile(&dbFile); } dbFile.dirty(false); } } if (transaction) { transaction->commit(); transaction = nullptr; } return dbFiles; } void AudioDirectoryInfoImpl::DbDeleteFile(DbFileInfo *dbFile) { if (this->audioFilesDb) { audioFilesDb->DeleteFile(dbFile); } } ThumbnailTemporaryFile AudioDirectoryInfo::DefaultThumbnailTemporaryFile() { ThumbnailTemporaryFile tempFile; fs::path defaultThumbnail = GetResourceDirectory() / "img/missing_thumbnail.jpg"; tempFile.SetNonDeletedPath(defaultThumbnail, "image/jpeg"); return tempFile; } ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetUnindexedThunbnail( const std::string &fileNameOnly, int32_t width, int32_t height) { fs::path file = this->path / fileNameOnly; if (!fs::exists(file)) { return DefaultThumbnailTemporaryFile(); } try { auto tempFile = pipedal::GetAudioFileThumbnail( file, width, height, GetTemporaryDirectory()); auto thumbnailPath = tempFile.Path(); ThumbnailTemporaryFile result; result.Attach(tempFile.Detach(), "image/jpeg"); // Read the thumbnail data from the temporary file. std::ifstream thumbnailStream(thumbnailPath, std::ios::binary); if (!thumbnailStream) { throw std::runtime_error("Failed to open thumbnail file: " + thumbnailPath.string()); } std::vector thumbnailData( (std::istreambuf_iterator(thumbnailStream)), std::istreambuf_iterator()); if (!thumbnailData.empty()) { audioFilesDb->AddThumbnail( fileNameOnly, width, height, thumbnailData); // Set the MIME type for the thumbnail. result.SetMimeType("image/jpeg"); audioFilesDb->UpdateThumbnailInfo( fileNameOnly, ThumbnailType::Embedded); return result; } else { throw std::runtime_error("Thumbnail data is empty."); } } catch (const std::exception &e) { Lv2Log::error("GetUnindexedThunbnail failed: %s", e.what()); } return DefaultThumbnailTemporaryFile(); } 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 { ThumbnailInfo thumbnailInfo = audioFilesDb->GetThumbnailInfo(file.filename().string()); if (thumbnailInfo.thumbnailType() == ThumbnailType::Folder) { fs::path thumbnailFile = path / thumbnailInfo.thumbnailFile(); if (!fs::exists(thumbnailFile) || fileTimeToInt64(fs::last_write_time(thumbnailFile)) != thumbnailInfo.thumbnailLastModified()) { // reset the thumbnail type and try again. audioFilesDb->UpdateThumbnailInfo( fileNameOnly, ThumbnailType::Unknown); return GetThumbnail(fileNameOnly, width, height); } fs::path fullFolderPath = this->path / thumbnailInfo.thumbnailFile(); return GetFolderTemporaryFile(fullFolderPath); } if (thumbnailInfo.thumbnailType() == ThumbnailType::Embedded) { std::vector blob = audioFilesDb->GetEmbeddedThumbnail(fileNameOnly, width, height); if (!blob.empty()) { ThumbnailTemporaryFile tempFile = ThumbnailTemporaryFile::CreateTemporaryFile(GetTemporaryDirectory(), "image/jpeg"); tempFile.SetMimeType("image/jpeg"); // write buffer to tempFile.GetPath() std::ofstream outFile(tempFile.Path(), std::ios::binary); if (!outFile) { throw std::runtime_error("Failed to open temporary file for writing: " + tempFile.Path().string()); } outFile.write((const char *)blob.data(), blob.size()); return tempFile; } else { // fall through and generate the indexed thumbnail. } } if (thumbnailInfo.thumbnailType() == ThumbnailType::None) { return DefaultThumbnailTemporaryFile(); } try { auto tempFile = pipedal::GetAudioFileThumbnail( this->path / fileNameOnly, width, height, GetTemporaryDirectory()); auto thumbnailPath = tempFile.Path(); ThumbnailTemporaryFile result; result.Attach(tempFile.Detach(), "image/jpeg"); // Read the thumbnail data from the temporary file. std::ifstream thumbnailStream(thumbnailPath, std::ios::binary); if (!thumbnailStream) { throw std::runtime_error("Failed to open thumbnail file: " + thumbnailPath.string()); } std::vector thumbnailData( (std::istreambuf_iterator(thumbnailStream)), std::istreambuf_iterator()); if (!thumbnailData.empty()) { audioFilesDb->AddThumbnail( fileNameOnly, width, height, thumbnailData); // Set the MIME type for the thumbnail. result.SetMimeType("image/jpeg"); audioFilesDb->UpdateThumbnailInfo( fileNameOnly, ThumbnailType::Embedded); return result; } else { throw std::runtime_error("Thumbnail data is empty."); } return result; } catch (const std::exception &_) { } auto folderFile = GetFolderFile(); if (!folderFile.empty()) { // We have a folder thumbnail, set the type and return it. fs::path t = this->path / folderFile; int64_t lastModified = fileTimeToInt64(fs::last_write_time(t)); DbSetThumbnailType(fileNameOnly, ThumbnailType::Folder, folderFile, lastModified); auto fullFolderPath = this->path / folderFile; return GetFolderTemporaryFile(fullFolderPath); } DbSetThumbnailType( fileNameOnly, ThumbnailType::None); // No thumbnail available, set to None. return DefaultThumbnailTemporaryFile(); } catch (const std::exception &e) { } } return GetUnindexedThunbnail(fileNameOnly, width, height); } 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->artist(metadata.artist()); dbFile->albumArtist(metadata.albumArtist()); dbFile->lastModified(GetLastWriteTime(file)); dbFile->duration(metadata.duration()); } 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) { try { this->audioFilesDb = std::make_shared(this->path, indexPath); } catch (const SQLite::Exception &e) { Lv2Log::debug("Can't create .index.pipedal: %s - %s", e.what(), this->path.c_str()); } } } 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) { size_t next = i; while (true) { next = (next + 1) % files.size(); if (!isFolderArtwork(files[next].fileName())) { return files[next].fileName(); } if (next == i) { break; } } } } 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) { size_t next = i; while (true) { if (next == 0) { next = files.size() - 1; // wrap around to the last file. } else { --next; // go to the previous file. } if (!isFolderArtwork(files[next].fileName())) { return files[next].fileName(); } if (next == i) { break; } } } } return ""; } void AudioDirectoryInfoImpl::MoveAudioFile( const std::string &directory, int32_t fromPosition, int32_t toPosition) { OpenAudioDb(); if (!audioFilesDb) { throw std::runtime_error("Directory is not writable."); } auto files = this->UpdateDbFiles(); if (directory.empty() || fromPosition < 0 || toPosition < 0 || fromPosition >= static_cast(files.size()) || toPosition > static_cast(files.size())) { throw std::invalid_argument("Invalid arguments for MoveAudioFile"); } std::shared_ptr transaction; if (audioFilesDb) { transaction = audioFilesDb->transaction(); } if (fromPosition == toPosition) { return; // No change needed. } if (fromPosition > toPosition) { auto t = files[fromPosition]; files.erase(files.begin() + fromPosition); files.insert(files.begin() + toPosition, t); } else { // fromPosition < toPosition auto t = files[fromPosition]; files.erase(files.begin() + fromPosition); files.insert(files.begin() + toPosition, t); // insert before the toPosition. } for (size_t i = 0; i < files.size(); ++i) { auto &file = files[i]; if (file.position() != static_cast(i)) { file.position(static_cast(i)); if (this->audioFilesDb) { audioFilesDb->UpdateFilePosition(file.idFile(), file.position()); } else { throw std::runtime_error("Directory is not writable."); } } } transaction->commit(); } namespace pipedal { std::filesystem::path IndexPathToShadowIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) { std::filesystem::path relativePath = MakeRelativePath(path, audioRootDirectory); if (relativePath.is_absolute()) { throw std::runtime_error(SS("Can't write to path " << path << ".")); }; return audioRootDirectory / "shadow_indexes" / relativePath; } // recover the original path fromthe shadow index path. std::filesystem::path ShadowIndexPathToIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) { auto shadowIndexesPath = audioRootDirectory / "shadow_indexes"; std::filesystem::path relativePath = MakeRelativePath(path, shadowIndexesPath); if (relativePath.is_absolute()) { throw std::runtime_error(SS("Path must start with " << shadowIndexesPath)); } return audioRootDirectory / relativePath; // recover the original path. } std::filesystem::path GetShadowIndexDirectory(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) { if (HasWritePermissions(path)) { return ""; // use default index path. } return IndexPathToShadowIndexPath(audioRootDirectory, path); } }