diff --git a/cpp/server/CMakeLists.txt b/cpp/server/CMakeLists.txt index ea1e78b4..f7bef0e5 100644 --- a/cpp/server/CMakeLists.txt +++ b/cpp/server/CMakeLists.txt @@ -133,6 +133,7 @@ set( gzip.cpp gzip.hpp http.cpp http.hpp json.hpp + library_controller.cpp library_controller.hpp log.cpp log.hpp outputs_controller.cpp outputs_controller.hpp parsing.cpp parsing.hpp diff --git a/cpp/server/artwork_controller.cpp b/cpp/server/artwork_controller.cpp index 9d398814..73edf736 100644 --- a/cpp/server/artwork_controller.cpp +++ b/cpp/server/artwork_controller.cpp @@ -44,6 +44,28 @@ ResponsePtr ArtworkController::getArtwork() return Response::async(std::move(responseFuture)); } +ResponsePtr ArtworkController::getLibraryArtwork() +{ + if (!player_->supportsLibrary()) + { + return Response::error( + HttpStatus::S_501_NOT_IMPLEMENTED, "media library is not supported by this player"); + } + + LibraryItemRef item; + + item.path = param("path"); + item.subsong = optionalParam("subsong", -1); + + auto responseFuture = player_->fetchLibraryArtwork(item).then( + boost::launch::sync, [this](boost::unique_future resultFuture) { + auto result = resultFuture.get(); + return getResponse(&result); + }); + + return Response::async(std::move(responseFuture)); +} + ResponsePtr ArtworkController::getResponse(ArtworkResult* result) { if (!result->filePath.empty()) @@ -94,6 +116,7 @@ void ArtworkController::defineRoutes( routes.useWorkQueue(workQueue); routes.setPrefix("api/artwork"); routes.get("current", &ArtworkController::getCurrentArtwork); + routes.get("library", &ArtworkController::getLibraryArtwork); routes.get(":plref/:index", &ArtworkController::getArtwork); } diff --git a/cpp/server/artwork_controller.hpp b/cpp/server/artwork_controller.hpp index 195cbe9f..3b56c6e5 100644 --- a/cpp/server/artwork_controller.hpp +++ b/cpp/server/artwork_controller.hpp @@ -21,6 +21,7 @@ class ArtworkController : public ControllerBase ResponsePtr getCurrentArtwork(); ResponsePtr getArtwork(); + ResponsePtr getLibraryArtwork(); static void defineRoutes(Router* router, WorkQueue* workQueue, Player* player, const ContentTypeMap& contentTypes); diff --git a/cpp/server/foobar2000/CMakeLists.txt b/cpp/server/foobar2000/CMakeLists.txt index 00cf17b0..93cfd9b8 100644 --- a/cpp/server/foobar2000/CMakeLists.txt +++ b/cpp/server/foobar2000/CMakeLists.txt @@ -14,6 +14,7 @@ set( common.hpp player.hpp player_control.cpp + player_library.cpp player_misc.cpp player_options.cpp player_options.hpp player_playlists.cpp diff --git a/cpp/server/foobar2000/player.hpp b/cpp/server/foobar2000/player.hpp index 60b0daef..ff87627a 100644 --- a/cpp/server/foobar2000/player.hpp +++ b/cpp/server/foobar2000/player.hpp @@ -103,6 +103,23 @@ class PlayerImpl final : public Player OutputsInfo getOutputs() override; void setOutputDevice(const std::string& typeId, const std::string& deviceId) override; + bool supportsLibrary() override; + LibraryInfo getLibraryInfo() override; + + LibraryItemsResult getLibraryItems( + const LibraryQuery& query, const Range& range, ColumnsQuery* columns) override; + + LibraryNodesResult getLibraryNodes( + const LibraryQuery& query, const Range& range, ColumnsQuery* columns) override; + + void addLibraryItems( + const PlaylistRef& plref, + const LibraryItemQuery& query, + int32_t targetIndex, + AddItemsOptions options) override; + + boost::unique_future fetchLibraryArtwork(const LibraryItemRef& item) override; + boost::unique_future fetchCurrentArtwork() override; boost::unique_future fetchArtwork(const ArtworkQuery& query) override; @@ -131,6 +148,13 @@ class PlayerImpl final : public Player std::vector evaluatePlaybackColumns( const TitleFormatVector& compiledColumns); + std::vector evaluateItemColumns( + const metadb_handle_ptr& item, + const TitleFormatVector& compiledColumns, + pfc::string8* buffer); + + void collectLibraryItems(const LibraryItemQuery& query, metadb_handle_list* outItems); + void makeItemsMask( t_size playlist, const std::vector& indexes, diff --git a/cpp/server/foobar2000/player_library.cpp b/cpp/server/foobar2000/player_library.cpp new file mode 100644 index 00000000..7a908b09 --- /dev/null +++ b/cpp/server/foobar2000/player_library.cpp @@ -0,0 +1,549 @@ +#include "player.hpp" +#include "file_system.hpp" + +#include +#include + +namespace msrv { +namespace player_foobar2000 { + +namespace { + +// Media library paths are identifiers used within the API only, +// they are kept platform independent and are never passed to the file system +constexpr char PATH_SEPARATOR = '/'; + +#ifdef MSRV_OS_WINDOWS +constexpr char NATIVE_PATH_SEPARATOR = '\\'; +#else +constexpr char NATIVE_PATH_SEPARATOR = '/'; +#endif + +bool isSeparator(char ch) +{ + return ch == PATH_SEPARATOR; +} + +size_t findSeparator(const std::string& path, size_t start) +{ + for (size_t i = start; i < path.length(); i++) + { + if (isSeparator(path[i])) + return i; + } + + return std::string::npos; +} + +std::string normalizeNodePath(const std::string& path) +{ + size_t start = 0; + size_t end = path.length(); + + while (end > start && isSeparator(path[end - 1])) + end--; + + while (start < end && isSeparator(path[start])) + start++; + + return path.substr(start, end - start); +} + +std::string joinNodePath(const std::string& prefix, const std::string& name) +{ + return prefix.empty() ? name : prefix + PATH_SEPARATOR + name; +} + +using NodeItem = std::pair; + +// Single file may hold several tracks (cue sheets), keep such tracks in subsong order +void sortItems(std::vector* items) +{ + std::sort(items->begin(), items->end(), [](const NodeItem& left, const NodeItem& right) { + if (left.first != right.first) + return left.first < right.first; + + return left.second->get_location().get_subsong() < right.second->get_location().get_subsong(); + }); +} + +// Folder is expected to be normalized, empty folder is the top level and contains everything +bool isSubpath(const std::string& path, const std::string& folder) +{ + if (folder.empty()) + return true; + + return path.length() > folder.length() + && isSeparator(path[folder.length()]) + && path.compare(0, folder.length(), folder) == 0; +} + +// Item reference matches a single track when it has a subsong, all tracks of a file +// when it does not, everything below it when it points to a folder, +// and the whole library when its path is empty +bool matchesRef(const LibraryItemRef& ref, const std::string& itemPath, const metadb_handle_ptr& item) +{ + auto path = normalizeNodePath(ref.path); + + if (!path.empty() && itemPath == path) + { + return ref.subsong < 0 + || static_cast(ref.subsong) == item->get_location().get_subsong(); + } + + return isSubpath(itemPath, path); +} + +// Item locations are prefixed with a scheme, plain file system path is what artwork lookup needs +std::string getAbsolutePath(const metadb_handle_ptr& item) +{ + constexpr char fileScheme[] = "file://"; + constexpr size_t fileSchemeLength = sizeof(fileScheme) - 1; + + auto path = item->get_path(); + + if (::strncmp(path, fileScheme, fileSchemeLength) == 0) + path += fileSchemeLength; + + return std::string(path); +} + +// Folders may hold their own artwork which is unrelated to artwork of the tracks below, +// this is what a folder view is expected to show +std::string findFolderArtwork(const std::string& folderPath) +{ + static const char* const names[] = {"folder", "cover", "front", "album", "artwork"}; + static const char* const extensions[] = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}; + + auto folder = pathFromUtf8(folderPath); + + for (auto name : names) + { + for (auto extension : extensions) + { + auto file = folder / pathFromUtf8(std::string(name) + extension); + auto info = file_io::tryQueryInfo(file); + + if (info && info->type == FileType::REGULAR) + return pathToUtf8(file); + } + } + + return std::string(); +} + +// Media library items are addressed by path relative to the media library folder they belong to, +// so that browsing starts at library folders instead of file system roots +std::string getNodePath( + const library_manager::ptr& libraryManager, + const metadb_handle_ptr& item, + pfc::string8* buffer) +{ + std::string path; + + if (libraryManager->get_relative_path(item, *buffer)) + { + path.assign(buffer->get_ptr(), buffer->get_length()); + } + else + { + // Should not happen for items coming from the media library, + // fall back to full path so that an item is never silently dropped + path = getAbsolutePath(item); + } + + std::replace(path.begin(), path.end(), NATIVE_PATH_SEPARATOR, PATH_SEPARATOR); + + return path; +} + +// Absolute paths keep the native separator, node paths do not +bool endsWithNodePath(const std::string& absolutePath, const std::string& nodePath) +{ + if (absolutePath.length() < nodePath.length()) + return false; + + auto offset = absolutePath.length() - nodePath.length(); + + for (size_t i = 0; i < nodePath.length(); i++) + { + auto left = absolutePath[offset + i]; + auto right = nodePath[i]; + + if (left == right) + continue; + + if (left == NATIVE_PATH_SEPARATOR && right == PATH_SEPARATOR) + continue; + + return false; + } + + return true; +} + +class ItemCounter : public library_manager::enum_callback +{ +public: + bool on_item(const metadb_handle_ptr& item) override + { + (void) item; + count_++; + return true; + } + + t_size count() const + { + return count_; + } + +private: + t_size count_ = 0; +}; + +void filterItems(metadb_handle_list* items, const std::string& expression) +{ + auto count = items->get_count(); + if (count == 0) + return; + + search_filter_v2::ptr filter; + + try + { + filter = search_filter_manager_v2::get()->create_ex( + expression.c_str(), + completion_notify::ptr(), + search_filter_manager_v2::KFlagSuppressNotify); + } + catch (std::exception& ex) + { + throw InvalidRequestException("invalid search query: " + std::string(ex.what())); + } + + if (filter.is_empty()) + throw InvalidRequestException("invalid search query: " + expression); + + pfc::array_t matches; + matches.set_size(count); + + filter->test_multi(*items, matches.get_ptr()); + + metadb_handle_list result; + result.prealloc(count); + + for (t_size i = 0; i < count; i++) + { + if (matches[i]) + result.add_item((*items)[i]); + } + + *items = result; +} + +} + +bool PlayerImpl::supportsLibrary() +{ + return true; +} + +LibraryInfo PlayerImpl::getLibraryInfo() +{ + auto libraryManager = library_manager::get(); + + ItemCounter counter; + libraryManager->enum_items(counter); + + LibraryInfo info; + info.supported = true; + info.enabled = libraryManager->is_library_enabled(); + info.itemCount = static_cast(counter.count()); + return info; +} + +LibraryItemsResult PlayerImpl::getLibraryItems( + const LibraryQuery& query, const Range& range, ColumnsQuery* columns) +{ + auto queryImpl = dynamic_cast(columns); + if (!queryImpl) + throw std::logic_error("ColumnsQueryImpl is required"); + + metadb_handle_list items; + library_manager::get()->get_all_items(items); + + if (!query.search.empty()) + filterItems(&items, query.search); + + if (query.sortBy.empty()) + { + // Media library content has no inherent order, sort by path to make paging stable + metadb_handle_list_helper::sort_by_path(items); + } + else + { + titleformat_object::ptr sortBy; + + if (!titleFormatCompiler_->compile(sortBy, query.sortBy.c_str())) + throw InvalidRequestException("invalid format expression: " + query.sortBy); + + metadb_handle_list_helper::sort_by_format( + items, sortBy, nullptr, query.sortDescending ? -1 : 1); + } + + auto totalCount = items.get_count(); + auto offset = std::min(static_cast(range.offset), totalCount); + auto endOffset = std::min(static_cast(range.endOffset()), totalCount); + + std::vector result; + + if (offset < endOffset) + { + result.reserve(endOffset - offset); + + auto libraryManager = library_manager::get(); + pfc::string8 buffer; + + for (t_size i = offset; i < endOffset; i++) + { + const auto& item = items[i]; + + LibraryItemInfo info; + info.path = getNodePath(libraryManager, item, &buffer); + info.subsong = static_cast(item->get_location().get_subsong()); + info.columns = evaluateItemColumns(item, queryImpl->columns, &buffer); + result.emplace_back(std::move(info)); + } + } + + return LibraryItemsResult( + static_cast(offset), + static_cast(totalCount), + std::move(result)); +} + +void PlayerImpl::collectLibraryItems(const LibraryItemQuery& query, metadb_handle_list* outItems) +{ + auto libraryManager = library_manager::get(); + + metadb_handle_list items; + libraryManager->get_all_items(items); + + if (!query.search.empty()) + filterItems(&items, query.search); + + std::vector matches; + pfc::string8 buffer; + + for (t_size i = 0; i < items.get_count(); i++) + { + const auto& item = items[i]; + auto itemPath = getNodePath(libraryManager, item, &buffer); + + bool matched = query.items.empty(); + + for (auto it = query.items.begin(); !matched && it != query.items.end(); ++it) + matched = matchesRef(*it, itemPath, item); + + if (matched) + matches.emplace_back(std::move(itemPath), item); + } + + sortItems(&matches); + + outItems->remove_all(); + outItems->prealloc(matches.size()); + + for (auto& match : matches) + outItems->add_item(match.second); +} + +void PlayerImpl::addLibraryItems( + const PlaylistRef& plref, + const LibraryItemQuery& query, + int32_t targetIndex, + AddItemsOptions options) +{ + metadb_handle_list items; + collectLibraryItems(query, &items); + + auto playlist = playlists_->getIndex(plref); + auto hasAddedItems = items.get_count() > 0; + t_size itemIndex; + + if (hasFlags(options, AddItemsOptions::REPLACE)) + { + playlistManager_->playlist_clear(playlist); + itemIndex = 0; + } + else + { + auto itemCount = playlistManager_->playlist_get_item_count(playlist); + + itemIndex = targetIndex >= 0 && static_cast(targetIndex) < itemCount + ? static_cast(targetIndex) + : itemCount; + } + + if (hasAddedItems) + playlistManager_->playlist_insert_items(playlist, itemIndex, items, bit_array_false()); + + if (!hasFlags(options, AddItemsOptions::PLAY)) + return; + + if (hasAddedItems) + { + playlistManager_->set_active_playlist(playlist); + playlistManager_->playlist_execute_default_action(playlist, itemIndex); + } + else + { + playbackControl_->stop(); + } +} + +boost::unique_future PlayerImpl::fetchLibraryArtwork(const LibraryItemRef& item) +{ + LibraryItemQuery query; + query.items.emplace_back(item); + + metadb_handle_list items; + collectLibraryItems(query, &items); + + if (items.get_count() == 0) + return boost::make_future(ArtworkResult()); + + const auto& firstItem = items[0]; + auto path = normalizeNodePath(item.path); + + pfc::string8 buffer; + auto nodePath = getNodePath(library_manager::get(), firstItem, &buffer); + + // Query addresses a folder rather than a single file, prefer artwork stored in that folder + if (!path.empty() && nodePath.length() > path.length()) + { + auto absolutePath = getAbsolutePath(firstItem); + auto suffixLength = nodePath.length() - path.length(); + + if (absolutePath.length() > suffixLength && endsWithNodePath(absolutePath, nodePath)) + { + auto folderPath = absolutePath.substr(0, absolutePath.length() - suffixLength); + auto artwork = findFolderArtwork(folderPath); + + if (!artwork.empty()) + return boost::make_future(ArtworkResult(std::move(artwork))); + } + } + + return fetchArtwork(firstItem); +} + +LibraryNodesResult PlayerImpl::getLibraryNodes( + const LibraryQuery& query, const Range& range, ColumnsQuery* columns) +{ + auto queryImpl = dynamic_cast(columns); + if (!queryImpl) + throw std::logic_error("ColumnsQueryImpl is required"); + + auto libraryManager = library_manager::get(); + + metadb_handle_list items; + libraryManager->get_all_items(items); + + if (!query.search.empty()) + filterItems(&items, query.search); + + auto prefix = normalizeNodePath(query.path); + auto childOffset = prefix.empty() ? 0 : prefix.length() + 1; + + std::map folders; + std::vector files; + + pfc::string8 buffer; + + for (t_size i = 0; i < items.get_count(); i++) + { + const auto& item = items[i]; + auto path = getNodePath(libraryManager, item, &buffer); + + if (!isSubpath(path, prefix)) + continue; + + auto separator = findSeparator(path, childOffset); + + if (separator == std::string::npos) + files.emplace_back(path.substr(childOffset), item); + else + folders[path.substr(childOffset, separator - childOffset)]++; + } + + sortItems(&files); + + std::vector nodes; + std::vector handles; + + nodes.reserve(folders.size() + files.size()); + handles.reserve(folders.size() + files.size()); + + for (auto& folder : folders) + { + LibraryNodeInfo node; + node.isFolder = true; + node.name = folder.first; + node.path = joinNodePath(prefix, folder.first); + node.itemCount = folder.second; + nodes.emplace_back(std::move(node)); + handles.emplace_back(); + } + + for (auto& file : files) + { + LibraryNodeInfo node; + node.name = file.first; + node.path = joinNodePath(prefix, file.first); + node.subsong = static_cast(file.second->get_location().get_subsong()); + nodes.emplace_back(std::move(node)); + handles.emplace_back(file.second); + } + + auto totalCount = nodes.size(); + auto offset = std::min(static_cast(range.offset), totalCount); + auto endOffset = std::min(static_cast(range.endOffset()), totalCount); + + std::vector result; + + if (offset < endOffset) + { + result.reserve(endOffset - offset); + + for (size_t i = offset; i < endOffset; i++) + { + if (handles[i].is_valid()) + nodes[i].columns = evaluateItemColumns(handles[i], queryImpl->columns, &buffer); + + result.emplace_back(std::move(nodes[i])); + } + } + + LibraryNodesResult nodesResult( + static_cast(offset), + static_cast(totalCount), + std::move(result)); + + nodesResult.path = prefix; + + if (!prefix.empty()) + { + auto separator = prefix.find_last_of("\\/"); + + nodesResult.hasParent = true; + nodesResult.parentPath = separator == std::string::npos + ? std::string() + : prefix.substr(0, separator); + } + + return nodesResult; +} + +} +} diff --git a/cpp/server/foobar2000/player_misc.cpp b/cpp/server/foobar2000/player_misc.cpp index 87ae4c49..2308f31c 100644 --- a/cpp/server/foobar2000/player_misc.cpp +++ b/cpp/server/foobar2000/player_misc.cpp @@ -193,6 +193,23 @@ boost::unique_future PlayerImpl::fetchArtwork(const metadb_handle return boost::make_future(ArtworkResult(artData->get_ptr(), artData->get_size())); } +std::vector PlayerImpl::evaluateItemColumns( + const metadb_handle_ptr& item, + const TitleFormatVector& compiledColumns, + pfc::string8* buffer) +{ + std::vector result; + result.reserve(compiledColumns.size()); + + for (auto& compiledColumn : compiledColumns) + { + item->format_title(nullptr, *buffer, compiledColumn, nullptr); + result.emplace_back(buffer->get_ptr(), buffer->get_length()); + } + + return result; +} + TitleFormatVector PlayerImpl::compileColumns(const std::vector& columns) { TitleFormatVector compiledColumns; diff --git a/cpp/server/library_controller.cpp b/cpp/server/library_controller.cpp new file mode 100644 index 00000000..8f0b8225 --- /dev/null +++ b/cpp/server/library_controller.cpp @@ -0,0 +1,80 @@ +#include "library_controller.hpp" +#include "router.hpp" +#include "core_types_parsers.hpp" +#include "core_types_json.hpp" +#include "player_api.hpp" +#include "player_api_json.hpp" +#include "player_api_parsers.hpp" + +#include + +namespace msrv { + +LibraryController::LibraryController(Request* request, Player* player) + : ControllerBase(request), player_(player) +{ +} + +LibraryController::~LibraryController() = default; + +ResponsePtr LibraryController::getInfo() +{ + return Response::json({{"library", player_->getLibraryInfo()}}); +} + +ResponsePtr LibraryController::notSupportedResponse() +{ + return Response::error( + HttpStatus::S_501_NOT_IMPLEMENTED, "media library is not supported by this player"); +} + +Range LibraryController::readRange() +{ + // Media library results are produced by applying search criteria, not naturally ordered, + // so paging is optional and everything is returned by default + return optionalParam("range", Range(0, std::numeric_limits::max())); +} + +ResponsePtr LibraryController::getItems() +{ + if (!player_->supportsLibrary()) + return notSupportedResponse(); + + auto columnsQuery = player_->createColumnsQuery(param>("columns")); + + LibraryQuery query; + query.search = optionalParam("query", std::string()); + query.sortBy = optionalParam("sort", std::string()); + query.sortDescending = optionalParam("desc", false); + + return Response::json({{"libraryItems", player_->getLibraryItems(query, readRange(), columnsQuery.get())}}); +} + +ResponsePtr LibraryController::getItemsByPath() +{ + if (!player_->supportsLibrary()) + return notSupportedResponse(); + + auto columnsQuery = player_->createColumnsQuery(param>("columns")); + + LibraryQuery query; + query.path = optionalParam("path", std::string()); + query.search = optionalParam("query", std::string()); + + return Response::json({{"libraryNodes", player_->getLibraryNodes(query, readRange(), columnsQuery.get())}}); +} + +void LibraryController::defineRoutes(Router* router, WorkQueue* workQueue, Player* player) +{ + auto routes = router->defineRoutes(); + + routes.createWith([=](Request* request) { return new LibraryController(request, player); }); + routes.useWorkQueue(workQueue); + routes.setPrefix("api/library"); + + routes.get("info", &LibraryController::getInfo); + routes.get("items", &LibraryController::getItems); + routes.get("items/by-path", &LibraryController::getItemsByPath); +} + +} diff --git a/cpp/server/library_controller.hpp b/cpp/server/library_controller.hpp new file mode 100644 index 00000000..b3126e68 --- /dev/null +++ b/cpp/server/library_controller.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "defines.hpp" +#include "controller.hpp" + +namespace msrv { + +class Router; + +class Player; + +class WorkQueue; + +class LibraryController : public ControllerBase +{ +public: + LibraryController(Request* request, Player* player); + ~LibraryController(); + + ResponsePtr getInfo(); + ResponsePtr getItems(); + ResponsePtr getItemsByPath(); + + static void defineRoutes(Router* router, WorkQueue* workQueue, Player* player); + +private: + static ResponsePtr notSupportedResponse(); + + Range readRange(); + + Player* player_; + + MSRV_NO_COPY_AND_ASSIGN(LibraryController); +}; + +} diff --git a/cpp/server/player_api.hpp b/cpp/server/player_api.hpp index 2d5ddd6b..0786801f 100644 --- a/cpp/server/player_api.hpp +++ b/cpp/server/player_api.hpp @@ -273,6 +273,129 @@ struct ArtworkResult std::vector fileData; }; +struct LibraryInfo +{ + bool supported = false; + bool enabled = false; + int32_t itemCount = 0; +}; + +struct LibraryQuery +{ + LibraryQuery() = default; + LibraryQuery(LibraryQuery&&) = default; + LibraryQuery& operator=(LibraryQuery&&) = default; + + std::string search; + std::string sortBy; + bool sortDescending = false; + + // Folder to list children of, relative to media library folders, empty for top level + std::string path; +}; + +struct LibraryItemInfo +{ + LibraryItemInfo() = default; + LibraryItemInfo(LibraryItemInfo&&) = default; + LibraryItemInfo& operator=(LibraryItemInfo&&) = default; + + // Path relative to media library folders, together with subsong identifies a track + std::string path; + int32_t subsong = 0; + std::vector columns; +}; + +struct LibraryItemsResult +{ + LibraryItemsResult( + int32_t offsetVal, + int32_t totalCountVal, + std::vector itemsVal) + : offset(offsetVal), + totalCount(totalCountVal), + items(std::move(itemsVal)) + { + } + + LibraryItemsResult(LibraryItemsResult&&) = default; + LibraryItemsResult& operator=(LibraryItemsResult&&) = default; + + int32_t offset; + int32_t totalCount; + std::vector items; +}; + +struct LibraryNodeInfo +{ + LibraryNodeInfo() = default; + LibraryNodeInfo(LibraryNodeInfo&&) = default; + LibraryNodeInfo& operator=(LibraryNodeInfo&&) = default; + + bool isFolder = false; + std::string name; + std::string path; + int32_t itemCount = 0; + int32_t subsong = 0; + std::vector columns; +}; + +// Addresses media library content: a single track, all tracks of a file, +// everything under a folder or the whole library +struct LibraryItemRef +{ + LibraryItemRef() = default; + LibraryItemRef(LibraryItemRef&&) = default; + LibraryItemRef(const LibraryItemRef&) = default; + LibraryItemRef& operator=(LibraryItemRef&&) = default; + LibraryItemRef& operator=(const LibraryItemRef&) = default; + + // Item path as returned by media library queries, empty for the whole library + std::string path; + + // Subsong index of a track within its file, negative matches every subsong + int32_t subsong = -1; +}; + +struct LibraryItemQuery +{ + LibraryItemQuery() = default; + LibraryItemQuery(LibraryItemQuery&&) = default; + LibraryItemQuery& operator=(LibraryItemQuery&&) = default; + + // Items to address, empty list means everything matching search + std::vector items; + + std::string search; +}; + +struct LibraryNodesResult +{ + LibraryNodesResult( + int32_t offsetVal, + int32_t totalCountVal, + std::vector itemsVal) + : offset(offsetVal), + totalCount(totalCountVal), + items(std::move(itemsVal)) + { + } + + LibraryNodesResult(LibraryNodesResult&&) = default; + LibraryNodesResult& operator=(LibraryNodesResult&&) = default; + + int32_t offset; + int32_t totalCount; + std::vector items; + + // Folder these items belong to, empty at top level + std::string path; + + // Folder to navigate up to, only meaningful when hasParent is set + std::string parentPath; + bool hasParent = false; +}; + class PlayerOption { public: @@ -563,6 +686,59 @@ class Player (void) deviceId; } + // Media library API + + virtual bool supportsLibrary() + { + return false; + } + + virtual LibraryInfo getLibraryInfo() + { + return LibraryInfo(); + } + + virtual LibraryItemsResult getLibraryItems( + const LibraryQuery& query, const Range& range, ColumnsQuery* columns) + { + (void) query; + (void) range; + (void) columns; + + throw std::logic_error("media library is not supported by this player"); + } + + virtual LibraryNodesResult getLibraryNodes( + const LibraryQuery& query, const Range& range, ColumnsQuery* columns) + { + (void) query; + (void) range; + (void) columns; + + throw std::logic_error("media library is not supported by this player"); + } + + virtual void addLibraryItems( + const PlaylistRef& plref, + const LibraryItemQuery& query, + int32_t targetIndex, + AddItemsOptions options) + { + (void) plref; + (void) query; + (void) targetIndex; + (void) options; + + throw std::logic_error("media library is not supported by this player"); + } + + virtual boost::unique_future fetchLibraryArtwork(const LibraryItemRef& item) + { + (void) item; + + throw std::logic_error("media library is not supported by this player"); + } + // Artwork API virtual boost::unique_future fetchCurrentArtwork() = 0; diff --git a/cpp/server/player_api_json.cpp b/cpp/server/player_api_json.cpp index 0c4c8ddb..7e8cf962 100644 --- a/cpp/server/player_api_json.cpp +++ b/cpp/server/player_api_json.cpp @@ -187,6 +187,73 @@ void to_json(Json& json, const PlayQueueItemInfo& value) json["columns"] = value.columns; } +void to_json(Json& json, const LibraryInfo& value) +{ + json["supported"] = value.supported; + json["enabled"] = value.enabled; + json["itemCount"] = value.itemCount; +} + +void to_json(Json& json, const LibraryItemInfo& value) +{ + json["path"] = value.path; + json["subsong"] = value.subsong; + json["columns"] = value.columns; +} + +void to_json(Json& json, const LibraryItemsResult& value) +{ + json["offset"] = value.offset; + json["totalCount"] = value.totalCount; + json["items"] = value.items; +} + +void from_json(const Json& json, LibraryItemRef& value) +{ + if (json.is_string()) + { + value.path = json.get(); + return; + } + + if (!json.is_object()) + throw std::invalid_argument("Invalid media library item reference"); + + value.path = json.at("path").get(); + + auto subsong = json.find("subsong"); + if (subsong != json.end() && !subsong->is_null()) + value.subsong = subsong->get(); +} + +void to_json(Json& json, const LibraryNodeInfo& value) +{ + json["type"] = value.isFolder ? "D" : "F"; + json["name"] = value.name; + json["path"] = value.path; + + if (value.isFolder) + { + json["itemCount"] = value.itemCount; + } + else + { + json["subsong"] = value.subsong; + json["columns"] = value.columns; + } +} + +void to_json(Json& json, const LibraryNodesResult& value) +{ + json["offset"] = value.offset; + json["totalCount"] = value.totalCount; + json["items"] = value.items; + json["path"] = value.path; + + if (value.hasParent) + json["parentPath"] = value.parentPath; +} + void to_json(Json& json, const OutputDeviceInfo& value) { json["id"] = value.id; diff --git a/cpp/server/player_api_json.hpp b/cpp/server/player_api_json.hpp index 5256e685..5c5a82b8 100644 --- a/cpp/server/player_api_json.hpp +++ b/cpp/server/player_api_json.hpp @@ -17,6 +17,12 @@ void to_json(Json& json, const PlaylistInfo& value); void to_json(Json& json, const PlaylistItemInfo& value); void to_json(Json& json, const PlaylistItemsResult& value); void to_json(Json& json, const PlayQueueItemInfo& value); +void to_json(Json& json, const LibraryInfo& value); +void to_json(Json& json, const LibraryItemInfo& value); +void to_json(Json& json, const LibraryItemsResult& value); +void from_json(const Json& json, LibraryItemRef& value); +void to_json(Json& json, const LibraryNodeInfo& value); +void to_json(Json& json, const LibraryNodesResult& value); void to_json(Json& json, const OutputDeviceInfo& value); void to_json(Json& json, const OutputTypeInfo& value); void to_json(Json& json, const ActiveOutputInfo& value); diff --git a/cpp/server/playlists_controller.cpp b/cpp/server/playlists_controller.cpp index 75014fee..722b16c4 100644 --- a/cpp/server/playlists_controller.cpp +++ b/cpp/server/playlists_controller.cpp @@ -179,6 +179,39 @@ ResponsePtr PlaylistsController::addItems() } } +ResponsePtr PlaylistsController::addItemsFromLibrary() +{ + checkPermissions(); + + if (!player_->supportsLibrary()) + { + return Response::error( + HttpStatus::S_501_NOT_IMPLEMENTED, "media library is not supported by this player"); + } + + LibraryItemQuery query; + query.search = optionalParam("query", std::string()); + + if (auto items = optionalBodyParam>("items")) + query.items = std::move(*items); + + auto options = AddItemsOptions::NONE; + + if (optionalParam("replace", false)) + options |= AddItemsOptions::REPLACE; + + if (optionalParam("play", false)) + options |= AddItemsOptions::PLAY; + + player_->addLibraryItems( + param("plref"), + query, + optionalParam("index", -1), + options); + + return Response::ok(); +} + void PlaylistsController::moveItemsInPlaylist() { checkPermissions(); @@ -277,6 +310,10 @@ void PlaylistsController::defineRoutes(Router* router, WorkQueue* workQueue, Pla ":plref/items/add", ControllerAction(&PlaylistsController::addItems)); + routes.post( + ":plref/items/add-from-library", + ControllerAction(&PlaylistsController::addItemsFromLibrary)); + routes.post(":plref/items/move", &PlaylistsController::moveItemsInPlaylist); routes.post(":plref/items/copy", &PlaylistsController::copyItemsInPlaylist); routes.post(":plref/items/remove", &PlaylistsController::removeItems); diff --git a/cpp/server/playlists_controller.hpp b/cpp/server/playlists_controller.hpp index f308eee0..a2d20c72 100644 --- a/cpp/server/playlists_controller.hpp +++ b/cpp/server/playlists_controller.hpp @@ -29,6 +29,7 @@ class PlaylistsController : public ControllerBase void clearPlaylist(); ResponsePtr addItems(); + ResponsePtr addItemsFromLibrary(); void moveItemsInPlaylist(); void copyItemsInPlaylist(); diff --git a/cpp/server/server_host.cpp b/cpp/server/server_host.cpp index a868fae4..6bbcb629 100644 --- a/cpp/server/server_host.cpp +++ b/cpp/server/server_host.cpp @@ -5,6 +5,7 @@ #include "play_queue_controller.hpp" #include "player_controller.hpp" #include "playlists_controller.hpp" +#include "library_controller.hpp" #include "query_controller.hpp" #include "cache_support_filter.hpp" #include "compression_filter.hpp" @@ -59,6 +60,7 @@ void ServerHost::reconfigure(SettingsDataPtr settings) PlaylistsController::defineRoutes(router, playerQueue, player_, settings); PlayQueueController::defineRoutes(router, playerQueue, player_); OutputsController::defineRoutes(router, playerQueue, player_, settings); + LibraryController::defineRoutes(router, playerQueue, player_); QueryController::defineRoutes(router, playerQueue, player_, &dispatcher_, settings); ArtworkController::defineRoutes(router, playerQueue, player_, contentTypes_); diff --git a/docs/player-api.yml b/docs/player-api.yml index ee794a92..8ab3eaa8 100644 --- a/docs/player-api.yml +++ b/docs/player-api.yml @@ -15,6 +15,8 @@ tags: description: Output configuration APIs - name: query description: Query APIs +- name: library + description: Media library APIs (foobar2000 only) - name: browser description: File browser APIs - name: artwork @@ -734,6 +736,175 @@ paths: application/json: schema: $ref: '#/components/schemas/QueryResponse' + /library/info: + get: + tags: + - library + summary: Get media library state + operationId: getLibraryInfo + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetLibraryInfoResponse' + /library/items: + get: + tags: + - library + summary: Get media library items + operationId: getLibraryItems + parameters: + - name: range + in: query + description: > + Item range in form offset:count. + Media library items are produced by applying search criteria rather than + naturally ordered, so paging is optional and all items are returned by default + schema: + type: string + - name: columns + in: query + description: Item columns to return + required: true + style: form + explode: false + schema: + type: array + items: + type: string + - name: query + in: query + description: Search query to filter items, uses player query syntax + schema: + type: string + - name: sort + in: query + description: Title formatting expression to sort items by, defaults to file path + schema: + type: string + - name: desc + in: query + description: Sort in descending order + schema: + type: boolean + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetLibraryItemsResponse' + 501: + description: Media library is not supported by current player + content: {} + /library/items/by-path: + get: + tags: + - library + summary: Get media library items grouped by directory structure + operationId: getLibraryItemsByPath + description: > + Returns children of a single folder: subfolders first, then tracks. + Items are sorted by name + parameters: + - name: range + in: query + description: Item range in form offset:count, all items are returned by default + schema: + type: string + - name: columns + in: query + description: Columns to return for track items + required: true + style: form + explode: false + schema: + type: array + items: + type: string + - name: path + in: query + description: > + Folder to list children of, as returned in the "path" property of a folder item. + Empty or missing means top level + schema: + type: string + - name: query + in: query + description: Search query to filter items, uses player query syntax + schema: + type: string + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetLibraryItemsByPathResponse' + 501: + description: Media library is not supported by current player + content: {} + /playlists/{playlistId}/items/add-from-library: + post: + tags: + - playlists + summary: Add media library items to playlist + operationId: addPlaylistItemsFromLibrary + parameters: + - name: playlistId + in: path + description: Playlist id or index + required: true + schema: + type: string | integer + requestBody: + description: > + Items to add, addressed the same way as in media library browsing. + Omitting both path and query adds the entire media library + content: + application/json: + schema: + $ref: '#/components/schemas/AddPlaylistItemsFromLibraryRequest' + required: true + responses: + 204: + description: Success + content: {} + 501: + description: Media library is not supported by current player + content: {} + /artwork/library: + get: + tags: + - artwork + summary: Get artwork for media library item + operationId: getLibraryArtwork + parameters: + - name: path + in: query + description: Item path as returned by media library browsing + required: true + schema: + type: string + - name: subsong + in: query + description: > + Subsong index within the file, as returned in the "subsong" property. + When omitted artwork of the first matching track is returned + schema: + type: integer + responses: + 200: + description: Success + content: {} + 404: + description: No artwork is found for specified item + content: {} + 501: + description: Media library is not supported by current player + content: {} /browser/roots: get: tags: @@ -1056,6 +1227,139 @@ components: properties: playlistItems: $ref: '#/components/schemas/PlaylistItemsResult' + LibraryInfo: + type: object + properties: + supported: + type: boolean + description: Whether current player provides media library API + enabled: + type: boolean + description: Whether media library is configured in the player + itemCount: + type: integer + LibraryItemRef: + type: object + properties: + path: + type: string + description: > + Item path as returned by media library queries. + Selects a single file when it points to a track, + everything below it when it points to a folder + subsong: + type: integer + description: > + Subsong index within the file, selects a single track of a cue sheet or disc image. + When omitted all tracks of the file are selected + AddPlaylistItemsFromLibraryRequest: + type: object + properties: + items: + type: array + description: > + Items to add, as returned by media library queries. + When omitted everything matching query is added + items: + $ref: '#/components/schemas/LibraryItemRef' + query: + type: string + description: Search query to filter added items, uses player query syntax + index: + type: integer + description: Target position in playlist, appends when omitted + replace: + type: boolean + description: Replace playlist contents + play: + type: boolean + description: Start playing the first added item + GetLibraryInfoResponse: + type: object + properties: + library: + $ref: '#/components/schemas/LibraryInfo' + LibraryNodeInfo: + type: object + properties: + type: + type: string + enum: [D, F] + description: D for a folder, F for a track + name: + type: string + description: Name of the folder or file + path: + type: string + description: Path relative to media library folders, pass back as "path" to list children + itemCount: + type: integer + description: Number of tracks under this folder, folders only + subsong: + type: integer + description: > + Subsong index within the file, tracks only. + 0 for regular files, non-zero for tracks of cue sheets and disc images + columns: + type: array + description: Requested columns, tracks only + items: + type: string + LibraryNodesResult: + type: object + properties: + offset: + type: integer + totalCount: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/LibraryNodeInfo' + path: + type: string + description: Folder these items belong to, empty at top level + parentPath: + type: string + description: > + Folder to navigate up to, pass it back as "path". + Absent when already at top level + LibraryItemInfo: + type: object + properties: + path: + type: string + description: Path relative to media library folders, with subsong identifies a track + subsong: + type: integer + description: > + Subsong index within the file. + 0 for regular files, non-zero for tracks of cue sheets and disc images + columns: + type: array + items: + type: string + LibraryItemsResult: + type: object + properties: + offset: + type: integer + totalCount: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/LibraryItemInfo' + GetLibraryItemsResponse: + type: object + properties: + libraryItems: + $ref: '#/components/schemas/LibraryItemsResult' + GetLibraryItemsByPathResponse: + type: object + properties: + libraryNodes: + $ref: '#/components/schemas/LibraryNodesResult' UpdatePlaylistsRequest: type: object properties: diff --git a/js/api_tests/sources.cmake b/js/api_tests/sources.cmake index 30b0ce3e..fc7f2125 100644 --- a/js/api_tests/sources.cmake +++ b/js/api_tests/sources.cmake @@ -11,6 +11,8 @@ src/client_config_api_tests.js src/event_expectation.js src/http_features_tests.js src/install_app.js +src/install_plugin.js +src/library_api_tests.js src/outputs_api_tests.js src/permissions_tests.js src/play_queue_api_tests.js diff --git a/js/api_tests/src/library_api_tests.js b/js/api_tests/src/library_api_tests.js new file mode 100644 index 00000000..c65873a1 --- /dev/null +++ b/js/api_tests/src/library_api_tests.js @@ -0,0 +1,110 @@ +import { describe, test, assert } from 'vitest'; +import { client, config, setupPlayer } from './test_env.js'; +import { PlayerId } from './test_context.js'; + +const isSupported = config.playerId === PlayerId.foobar2000; + +describe('library api', () => { + setupPlayer(); + + test('get library info', async () => { + const info = await client.getLibraryInfo(); + + assert.equal(info.supported, isSupported); + assert.equal(typeof info.enabled, 'boolean'); + assert.equal(typeof info.itemCount, 'number'); + }); + + test('get library items', async () => { + if (!isSupported) + { + const response = await client.handler.axios.get( + '/api/library/items', + { params: { columns: ['%path%'] }, validateStatus: () => true }); + + assert.equal(response.status, 501); + return; + } + + const result = await client.getLibraryItems(['%path%', '%title%'], { offset: 0, count: 100 }); + + assert.equal(result.offset, 0); + assert.equal(typeof result.totalCount, 'number'); + assert.ok(Array.isArray(result.items)); + + for (const item of result.items) + { + assert.equal(item.columns.length, 2); + assert.equal(typeof item.path, 'string'); + assert.equal(typeof item.subsong, 'number'); + } + }); + + test('browse library folders', async () => { + if (!isSupported) + return; + + const result = await client.getLibraryItemsByPath('', ['%title%'], { offset: 0, count: 100 }); + + assert.equal(result.offset, 0); + assert.equal(typeof result.totalCount, 'number'); + assert.ok(Array.isArray(result.items)); + + // Top level has nowhere to navigate up to + assert.equal(result.path, ''); + assert.equal(result.parentPath, undefined); + + for (const item of result.items) + { + assert.ok(item.type === 'D' || item.type === 'F'); + assert.ok(typeof item.name === 'string'); + assert.ok(typeof item.path === 'string'); + } + }); + + test('library artwork for missing item', async () => { + const response = await client.handler.axios.get( + '/api/artwork/library', + { params: { path: 'no\\such\\track.flac' }, validateStatus: () => true }); + + assert.equal(response.status, isSupported ? 404 : 501); + }); + + test('add library items to playlist', async () => { + if (!isSupported) + return; + + const playlist = await client.addPlaylist({ title: 'library add test' }); + + // Library is empty in tests, adding everything must still succeed and change nothing + await client.addPlaylistItemsFromLibrary(playlist.id, {}); + + // Explicitly referenced items resolve to nothing for the same reason + await client.addPlaylistItemsFromLibrary( + playlist.id, { items: [{ path: 'no/such/track.flac', subsong: 0 }] }); + + const items = await client.getPlaylistItems(playlist.id, ['%path%'], { offset: 0, count: 100 }); + assert.equal(items.totalCount, 0); + }); + + test('browse by path requires supported player', async () => { + const response = await client.handler.axios.get( + '/api/library/items/by-path', + { params: { columns: ['%title%'] }, validateStatus: () => true }); + + assert.equal(response.status, isSupported ? 200 : 501); + }); + + test('get library items with query', async () => { + if (!isSupported) + return; + + const result = await client.getLibraryItems( + ['%path%'], + { offset: 0, count: 100 }, + { query: 'artist HAS beefweb_no_such_artist' }); + + assert.equal(result.totalCount, 0); + assert.deepEqual(result.items, []); + }); +}); diff --git a/js/api_tests/src/permissions_tests.js b/js/api_tests/src/permissions_tests.js index 7e543710..4c3baaac 100644 --- a/js/api_tests/src/permissions_tests.js +++ b/js/api_tests/src/permissions_tests.js @@ -36,6 +36,11 @@ describe('permissions', () => { assert.equal(response.status, 403); }); + test('add playlist items from library', async () => { + const response = await post('/api/playlists/0/items/add-from-library', { path: '' }); + assert.equal(response.status, 403); + }); + test('change output', async () => { const response = await post('/api/outputs/active', outputConfigs.alternate[0]); assert.equal(response.status, 403); diff --git a/js/client/src/player_client.js b/js/client/src/player_client.js index 983fbc21..ca75adde 100644 --- a/js/client/src/player_client.js +++ b/js/client/src/player_client.js @@ -1,6 +1,7 @@ import { skipUndefined, formatRange, + formatOptionalRange, parseRange, formatQueryOptions, isTransferBetweenPlaylists @@ -271,6 +272,38 @@ export default class PlayerClient return this.post('api/outputs/active', { typeId, deviceId }); } + getLibraryInfo() + { + return this.get('api/library/info').then(r => r.library); + } + + getLibraryItems(columns, range, options) + { + const params = Object.assign({ columns, range: formatOptionalRange(range) }, options); + return this.get('api/library/items', params).then(r => r.libraryItems); + } + + getLibraryItemsByPath(path, columns, range, options) + { + const params = Object.assign({ columns, path, range: formatOptionalRange(range) }, options); + return this.get('api/library/items/by-path', params).then(r => r.libraryNodes); + } + + addPlaylistItemsFromLibrary(plref, options) + { + return this.post(`api/playlists/${plref}/items/add-from-library`, options); + } + + getLibraryArtworkUrl(path, subsong) + { + const params = new URLSearchParams({ path }); + + if (subsong !== undefined) + params.set('subsong', subsong); + + return `api/artwork/library?${params}`; + } + getFileSystemRoots() { return this.get('api/browser/roots'); diff --git a/js/client/src/utils.js b/js/client/src/utils.js index fafe82d4..925c0b4b 100644 --- a/js/client/src/utils.js +++ b/js/client/src/utils.js @@ -53,6 +53,11 @@ export function formatRange(range) return `${range.offset}:${range.count}`; } +export function formatOptionalRange(arg) +{ + return arg === undefined ? undefined : formatRange(parseRange(arg)); +} + export function parseRange(arg) { switch (typeof arg)