From 8b29de49af34ac7a532781c09a1c65061e13a983 Mon Sep 17 00:00:00 2001 From: sb-3000 Date: Sat, 15 Aug 2026 15:45:36 -0400 Subject: [PATCH 1/2] feat(library): add media library browsing API --- cpp/server/CMakeLists.txt | 1 + cpp/server/artwork_controller.cpp | 23 ++ cpp/server/artwork_controller.hpp | 1 + cpp/server/foobar2000/CMakeLists.txt | 1 + cpp/server/foobar2000/player.hpp | 23 ++ cpp/server/foobar2000/player_library.cpp | 504 +++++++++++++++++++++++ cpp/server/library_controller.cpp | 107 +++++ cpp/server/library_controller.hpp | 36 ++ cpp/server/player_api.hpp | 129 ++++++ cpp/server/player_api_json.cpp | 36 ++ cpp/server/player_api_json.hpp | 3 + cpp/server/server_host.cpp | 2 + docs/player-api.yml | 233 +++++++++++ js/api_tests/sources.cmake | 2 + js/api_tests/src/library_api_tests.js | 103 +++++ js/api_tests/src/permissions_tests.js | 5 + js/client/src/player_client.js | 32 ++ 17 files changed, 1241 insertions(+) create mode 100644 cpp/server/foobar2000/player_library.cpp create mode 100644 cpp/server/library_controller.cpp create mode 100644 cpp/server/library_controller.hpp create mode 100644 js/api_tests/src/library_api_tests.js 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..9b2a8b04 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_->getLibraryInfo().supported) + { + return Response::error( + HttpStatus::S_501_NOT_IMPLEMENTED, "media library is not supported by this player"); + } + + LibraryItemQuery query; + + query.path = param("path"); + query.subsong = optionalParam("subsong", -1); + + auto responseFuture = player_->fetchLibraryArtwork(query).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..cbb7b866 100644 --- a/cpp/server/foobar2000/player.hpp +++ b/cpp/server/foobar2000/player.hpp @@ -103,6 +103,22 @@ class PlayerImpl final : public Player OutputsInfo getOutputs() override; void setOutputDevice(const std::string& typeId, const std::string& deviceId) override; + LibraryInfo getLibraryInfo() override; + + PlaylistItemsResult 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 LibraryItemQuery& query) override; + boost::unique_future fetchCurrentArtwork() override; boost::unique_future fetchArtwork(const ArtworkQuery& query) override; @@ -131,6 +147,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..c7f6d333 --- /dev/null +++ b/cpp/server/foobar2000/player_library.cpp @@ -0,0 +1,504 @@ +#include "player.hpp" +#include "file_system.hpp" + +#include +#include + +namespace msrv { +namespace player_foobar2000 { + +namespace { + +#ifdef MSRV_OS_WINDOWS +constexpr char PATH_SEPARATOR = '\\'; +#else +constexpr char PATH_SEPARATOR = '/'; +#endif + +bool isSeparator(char ch) +{ + return ch == '\\' || ch == '/'; +} + +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; +} + +// 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 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(); +} + +std::string getRelativePath( + const library_manager::ptr& libraryManager, + const metadb_handle_ptr& item, + pfc::string8* buffer) +{ + if (libraryManager->get_relative_path(item, *buffer)) + return std::string(buffer->get_ptr(), buffer->get_length()); + + return getAbsolutePath(item); +} + +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; +} + +} + +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; +} + +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; +} + +PlaylistItemsResult 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); + + pfc::string8 buffer; + + for (t_size item = offset; item < endOffset; item++) + result.emplace_back(evaluateItemColumns(items[item], queryImpl->columns, &buffer)); + } + + return PlaylistItemsResult( + static_cast(offset), + static_cast(totalCount), + std::move(result)); +} + +// Resolves a node path to media library items: +// exact match selects a single file (optionally a single subsong of it), +// prefix match selects everything under a folder, empty path selects the whole library +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); + + auto path = normalizeNodePath(query.path); + + std::vector> matches; + pfc::string8 buffer; + + for (t_size i = 0; i < items.get_count(); i++) + { + const auto& item = items[i]; + auto itemPath = getRelativePath(libraryManager, item, &buffer); + + if (!path.empty()) + { + if (itemPath.length() == path.length() && itemPath == path) + { + if (query.subsong >= 0 + && static_cast(query.subsong) != item->get_location().get_subsong()) + { + continue; + } + } + else if (itemPath.length() <= path.length() + || itemPath.compare(0, path.length(), path) != 0 + || !isSeparator(itemPath[path.length()])) + { + continue; + } + } + + matches.emplace_back(std::move(itemPath), item); + } + + std::sort(matches.begin(), matches.end(), [](const auto& left, const auto& right) { + if (left.first != right.first) + return left.first < right.first; + + return left.second->get_location().get_subsong() < right.second->get_location().get_subsong(); + }); + + 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 LibraryItemQuery& query) +{ + 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(query.path); + + pfc::string8 buffer; + auto relativePath = getRelativePath(library_manager::get(), firstItem, &buffer); + + // Query addresses a folder rather than a single file, prefer artwork stored in that folder + if (!path.empty() && relativePath.length() > path.length()) + { + auto absolutePath = getAbsolutePath(firstItem); + auto suffixLength = relativePath.length() - path.length(); + + if (absolutePath.length() > suffixLength + && absolutePath.compare(absolutePath.length() - relativePath.length(), relativePath.length(), relativePath) == 0) + { + 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 = getRelativePath(libraryManager, item, &buffer); + + if (!prefix.empty()) + { + if (path.length() <= childOffset + || path.compare(0, prefix.length(), prefix) != 0 + || !isSeparator(path[prefix.length()])) + { + 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)]++; + } + + // Single file may hold several tracks (cue sheets), keep such tracks in subsong order + std::sort(files.begin(), files.end(), [](const auto& left, const auto& right) { + if (left.first != right.first) + return left.first < right.first; + + return left.second->get_location().get_subsong() < right.second->get_location().get_subsong(); + }); + + 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; + nodesResult.pathSeparator = std::string(1, PATH_SEPARATOR); + + 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/library_controller.cpp b/cpp/server/library_controller.cpp new file mode 100644 index 00000000..6bd17576 --- /dev/null +++ b/cpp/server/library_controller.cpp @@ -0,0 +1,107 @@ +#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" + +namespace msrv { + +namespace { + +constexpr char VIEW_FLAT[] = "flat"; +constexpr char VIEW_FOLDERS[] = "folders"; + +} + +LibraryController::LibraryController(Request* request, Player* player, SettingsDataPtr settings) + : ControllerBase(request), player_(player), settings_(std::move(settings)) +{ +} + +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"); +} + +ResponsePtr LibraryController::getItems() +{ + if (!player_->getLibraryInfo().supported) + return notSupportedResponse(); + + auto range = param("range"); + auto columns = param>("columns"); + auto view = optionalParam("view", VIEW_FLAT); + + LibraryQuery query; + query.search = optionalParam("query", std::string()); + query.sortBy = optionalParam("sort", std::string()); + query.sortDescending = optionalParam("desc", false); + + auto columnsQuery = player_->createColumnsQuery(columns); + + if (view == VIEW_FOLDERS) + { + query.path = optionalParam("path", std::string()); + return Response::json({{"libraryItems", player_->getLibraryNodes(query, range, columnsQuery.get())}}); + } + + if (view != VIEW_FLAT) + throw InvalidRequestException("invalid view: " + view); + + return Response::json({{"libraryItems", player_->getLibraryItems(query, range, columnsQuery.get())}}); +} + +ResponsePtr LibraryController::addItems() +{ + settings_->ensurePermissions(ApiPermissions::CHANGE_PLAYLISTS); + + if (!player_->getLibraryInfo().supported) + return notSupportedResponse(); + + LibraryItemQuery query; + query.path = optionalParam("path", std::string()); + query.subsong = optionalParam("subsong", -1); + query.search = optionalParam("query", std::string()); + + 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 LibraryController::defineRoutes( + Router* router, WorkQueue* workQueue, Player* player, SettingsDataPtr settings) +{ + auto routes = router->defineRoutes(); + + routes.createWith([=](Request* request) { return new LibraryController(request, player, settings); }); + routes.useWorkQueue(workQueue); + routes.setPrefix("api/library"); + + routes.get("info", &LibraryController::getInfo); + routes.get("items/:range", &LibraryController::getItems); + routes.post("items/add", ControllerAction(&LibraryController::addItems)); +} + +} diff --git a/cpp/server/library_controller.hpp b/cpp/server/library_controller.hpp new file mode 100644 index 00000000..10f4b4f8 --- /dev/null +++ b/cpp/server/library_controller.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "defines.hpp" +#include "controller.hpp" +#include "settings.hpp" + +namespace msrv { + +class Router; + +class Player; + +class WorkQueue; + +class LibraryController : public ControllerBase +{ +public: + LibraryController(Request* request, Player* player, SettingsDataPtr settings); + ~LibraryController(); + + ResponsePtr getInfo(); + ResponsePtr getItems(); + ResponsePtr addItems(); + + static void defineRoutes(Router* router, WorkQueue* workQueue, Player* player, SettingsDataPtr settings); + +private: + static ResponsePtr notSupportedResponse(); + + Player* player_; + SettingsDataPtr settings_; + + MSRV_NO_COPY_AND_ASSIGN(LibraryController); +}; + +} diff --git a/cpp/server/player_api.hpp b/cpp/server/player_api.hpp index 2d5ddd6b..efd7a081 100644 --- a/cpp/server/player_api.hpp +++ b/cpp/server/player_api.hpp @@ -273,6 +273,87 @@ 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 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 LibraryItemQuery +{ + LibraryItemQuery() = default; + LibraryItemQuery(LibraryItemQuery&&) = default; + LibraryItemQuery& operator=(LibraryItemQuery&&) = default; + + // Node path as returned by getLibraryNodes(), empty for the whole library + std::string path; + + // Subsong index of a track within its file, negative matches every subsong + int32_t subsong = -1; + + 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; + + std::string pathSeparator; +}; + class PlayerOption { public: @@ -563,6 +644,54 @@ class Player (void) deviceId; } + // Media library API + + virtual LibraryInfo getLibraryInfo() + { + return LibraryInfo(); + } + + virtual PlaylistItemsResult 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 LibraryItemQuery& query) + { + (void) query; + + 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..f184e2ff 100644 --- a/cpp/server/player_api_json.cpp +++ b/cpp/server/player_api_json.cpp @@ -187,6 +187,42 @@ 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 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; + json["pathSeparator"] = value.pathSeparator; + + 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..884c0fb4 100644 --- a/cpp/server/player_api_json.hpp +++ b/cpp/server/player_api_json.hpp @@ -17,6 +17,9 @@ 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 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/server_host.cpp b/cpp/server/server_host.cpp index a868fae4..dc012a0c 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_, settings); 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..8db058cf 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,138 @@ 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/{range}: + get: + tags: + - library + summary: Get media library items + operationId: getLibraryItems + parameters: + - name: range + in: path + description: Item range in form offset:count + required: true + 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: view + in: query + description: > + How to present the media library: + "flat" returns a paged list of tracks, + "folders" returns children of a single folder (subfolders first, then tracks) + schema: + type: string + enum: [flat, folders] + default: flat + - name: path + in: query + description: > + Folder to list children of, as returned in the "path" property of a folder item. + Only used when view is "folders", 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 + - name: sort + in: query + description: > + Title formatting expression to sort items by, defaults to file path. + Not used when view is "folders", where items are sorted by name + 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/add: + post: + tags: + - library + summary: Add media library items to playlist + operationId: addLibraryItems + 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/AddLibraryItemsRequest' + 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 +1190,105 @@ 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 + AddLibraryItemsRequest: + type: object + properties: + plref: + type: string | integer + description: Target playlist id or index + path: + type: string + description: > + Item path as returned by media library browsing. + 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, adds a single track of a cue sheet or disc image. + When omitted all tracks of the file are added + 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 + pathSeparator: + type: string + GetLibraryItemsResponse: + type: object + properties: + libraryItems: + oneOf: + - $ref: '#/components/schemas/PlaylistItemsResult' + - $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..853d8787 --- /dev/null +++ b/js/api_tests/src/library_api_tests.js @@ -0,0 +1,103 @@ +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/0:100', + { 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); + }); + + test('browse library folders', async () => { + if (!isSupported) + return; + + const result = await client.browseLibrary('', ['%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); + assert.ok(result.pathSeparator); + + 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.addLibraryItems(playlist.id, { path: '' }); + + const items = await client.getPlaylistItems(playlist.id, ['%path%'], { offset: 0, count: 100 }); + assert.equal(items.totalCount, 0); + }); + + test('reject unknown view', async () => { + const response = await client.handler.axios.get( + '/api/library/items/0:10', + { params: { columns: ['%title%'], view: 'nonsense' }, validateStatus: () => true }); + + assert.equal(response.status, isSupported ? 400 : 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..eca3d176 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 library items', async () => { + const response = await post('/api/library/items/add', { plref: 0, 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..99febef8 100644 --- a/js/client/src/player_client.js +++ b/js/client/src/player_client.js @@ -271,6 +271,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 { offset, count } = parseRange(range); + const url = `api/library/items/${offset}:${count}`; + return this.get(url, Object.assign({ columns }, options)).then(r => r.libraryItems); + } + + browseLibrary(path, columns, range) + { + return this.getLibraryItems(columns, range, { view: 'folders', path }); + } + + addLibraryItems(plref, options) + { + return this.post('api/library/items/add', Object.assign({ plref }, 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'); From cfa9e06774cac72a0c725787f7e591804f81598e Mon Sep 17 00:00:00 2001 From: sb-3000 Date: Sun, 16 Aug 2026 09:36:19 -0400 Subject: [PATCH 2/2] refactor(library): address review feedback --- cpp/server/artwork_controller.cpp | 2 +- cpp/server/foobar2000/player.hpp | 1 + cpp/server/foobar2000/player_library.cpp | 161 +++++++++++++---------- cpp/server/foobar2000/player_misc.cpp | 17 +++ cpp/server/library_controller.cpp | 37 +++--- cpp/server/library_controller.hpp | 1 + cpp/server/player_api.hpp | 7 +- cpp/server/player_api_json.cpp | 1 - docs/player-api.yml | 80 +++++++---- js/api_tests/src/library_api_tests.js | 9 +- js/client/src/player_client.js | 6 +- 11 files changed, 200 insertions(+), 122 deletions(-) diff --git a/cpp/server/artwork_controller.cpp b/cpp/server/artwork_controller.cpp index 9b2a8b04..008915c3 100644 --- a/cpp/server/artwork_controller.cpp +++ b/cpp/server/artwork_controller.cpp @@ -46,7 +46,7 @@ ResponsePtr ArtworkController::getArtwork() ResponsePtr ArtworkController::getLibraryArtwork() { - if (!player_->getLibraryInfo().supported) + if (!player_->supportsLibrary()) { return Response::error( HttpStatus::S_501_NOT_IMPLEMENTED, "media library is not supported by this player"); diff --git a/cpp/server/foobar2000/player.hpp b/cpp/server/foobar2000/player.hpp index cbb7b866..56db8003 100644 --- a/cpp/server/foobar2000/player.hpp +++ b/cpp/server/foobar2000/player.hpp @@ -103,6 +103,7 @@ 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; PlaylistItemsResult getLibraryItems( diff --git a/cpp/server/foobar2000/player_library.cpp b/cpp/server/foobar2000/player_library.cpp index c7f6d333..487a87ae 100644 --- a/cpp/server/foobar2000/player_library.cpp +++ b/cpp/server/foobar2000/player_library.cpp @@ -9,15 +9,19 @@ 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 PATH_SEPARATOR = '\\'; +constexpr char NATIVE_PATH_SEPARATOR = '\\'; #else -constexpr char PATH_SEPARATOR = '/'; +constexpr char NATIVE_PATH_SEPARATOR = '/'; #endif bool isSeparator(char ch) { - return ch == '\\' || ch == '/'; + return ch == PATH_SEPARATOR; } size_t findSeparator(const std::string& path, size_t start) @@ -50,8 +54,31 @@ std::string joinNodePath(const std::string& prefix, const std::string& name) return prefix.empty() ? name : prefix + PATH_SEPARATOR + name; } -// 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 +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 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://"; @@ -89,15 +116,54 @@ std::string findFolderArtwork(const std::string& folderPath) return std::string(); } -std::string getRelativePath( +// 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)) - return std::string(buffer->get_ptr(), buffer->get_length()); + { + 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; - return getAbsolutePath(item); + 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 @@ -161,21 +227,9 @@ void filterItems(metadb_handle_list* items, const std::string& expression) } -std::vector PlayerImpl::evaluateItemColumns( - const metadb_handle_ptr& item, - const TitleFormatVector& compiledColumns, - pfc::string8* buffer) +bool PlayerImpl::supportsLibrary() { - 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; + return true; } LibraryInfo PlayerImpl::getLibraryInfo() @@ -258,41 +312,31 @@ void PlayerImpl::collectLibraryItems(const LibraryItemQuery& query, metadb_handl auto path = normalizeNodePath(query.path); - std::vector> matches; + std::vector matches; pfc::string8 buffer; for (t_size i = 0; i < items.get_count(); i++) { const auto& item = items[i]; - auto itemPath = getRelativePath(libraryManager, item, &buffer); + auto itemPath = getNodePath(libraryManager, item, &buffer); - if (!path.empty()) + if (!path.empty() && itemPath == path) { - if (itemPath.length() == path.length() && itemPath == path) - { - if (query.subsong >= 0 - && static_cast(query.subsong) != item->get_location().get_subsong()) - { - continue; - } - } - else if (itemPath.length() <= path.length() - || itemPath.compare(0, path.length(), path) != 0 - || !isSeparator(itemPath[path.length()])) + if (query.subsong >= 0 + && static_cast(query.subsong) != item->get_location().get_subsong()) { continue; } } + else if (!isSubpath(itemPath, path)) + { + continue; + } matches.emplace_back(std::move(itemPath), item); } - std::sort(matches.begin(), matches.end(), [](const auto& left, const auto& right) { - if (left.first != right.first) - return left.first < right.first; - - return left.second->get_location().get_subsong() < right.second->get_location().get_subsong(); - }); + sortItems(&matches); outItems->remove_all(); outItems->prealloc(matches.size()); @@ -357,16 +401,15 @@ boost::unique_future PlayerImpl::fetchLibraryArtwork(const Librar auto path = normalizeNodePath(query.path); pfc::string8 buffer; - auto relativePath = getRelativePath(library_manager::get(), firstItem, &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() && relativePath.length() > path.length()) + if (!path.empty() && nodePath.length() > path.length()) { auto absolutePath = getAbsolutePath(firstItem); - auto suffixLength = relativePath.length() - path.length(); + auto suffixLength = nodePath.length() - path.length(); - if (absolutePath.length() > suffixLength - && absolutePath.compare(absolutePath.length() - relativePath.length(), relativePath.length(), relativePath) == 0) + if (absolutePath.length() > suffixLength && endsWithNodePath(absolutePath, nodePath)) { auto folderPath = absolutePath.substr(0, absolutePath.length() - suffixLength); auto artwork = findFolderArtwork(folderPath); @@ -398,24 +441,17 @@ LibraryNodesResult PlayerImpl::getLibraryNodes( auto childOffset = prefix.empty() ? 0 : prefix.length() + 1; std::map folders; - std::vector> files; + std::vector files; pfc::string8 buffer; for (t_size i = 0; i < items.get_count(); i++) { const auto& item = items[i]; - auto path = getRelativePath(libraryManager, item, &buffer); + auto path = getNodePath(libraryManager, item, &buffer); - if (!prefix.empty()) - { - if (path.length() <= childOffset - || path.compare(0, prefix.length(), prefix) != 0 - || !isSeparator(path[prefix.length()])) - { - continue; - } - } + if (!isSubpath(path, prefix)) + continue; auto separator = findSeparator(path, childOffset); @@ -425,13 +461,7 @@ LibraryNodesResult PlayerImpl::getLibraryNodes( folders[path.substr(childOffset, separator - childOffset)]++; } - // Single file may hold several tracks (cue sheets), keep such tracks in subsong order - std::sort(files.begin(), files.end(), [](const auto& left, const auto& right) { - if (left.first != right.first) - return left.first < right.first; - - return left.second->get_location().get_subsong() < right.second->get_location().get_subsong(); - }); + sortItems(&files); std::vector nodes; std::vector handles; @@ -485,7 +515,6 @@ LibraryNodesResult PlayerImpl::getLibraryNodes( std::move(result)); nodesResult.path = prefix; - nodesResult.pathSeparator = std::string(1, PATH_SEPARATOR); if (!prefix.empty()) { 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 index 6bd17576..81b949fe 100644 --- a/cpp/server/library_controller.cpp +++ b/cpp/server/library_controller.cpp @@ -8,13 +8,6 @@ namespace msrv { -namespace { - -constexpr char VIEW_FLAT[] = "flat"; -constexpr char VIEW_FOLDERS[] = "folders"; - -} - LibraryController::LibraryController(Request* request, Player* player, SettingsDataPtr settings) : ControllerBase(request), player_(player), settings_(std::move(settings)) { @@ -35,37 +28,40 @@ ResponsePtr LibraryController::notSupportedResponse() ResponsePtr LibraryController::getItems() { - if (!player_->getLibraryInfo().supported) + if (!player_->supportsLibrary()) return notSupportedResponse(); auto range = param("range"); - auto columns = param>("columns"); - auto view = optionalParam("view", VIEW_FLAT); + 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); - auto columnsQuery = player_->createColumnsQuery(columns); + return Response::json({{"libraryItems", player_->getLibraryItems(query, range, columnsQuery.get())}}); +} + +ResponsePtr LibraryController::browse() +{ + if (!player_->supportsLibrary()) + return notSupportedResponse(); - if (view == VIEW_FOLDERS) - { - query.path = optionalParam("path", std::string()); - return Response::json({{"libraryItems", player_->getLibraryNodes(query, range, columnsQuery.get())}}); - } + auto range = param("range"); + auto columnsQuery = player_->createColumnsQuery(param>("columns")); - if (view != VIEW_FLAT) - throw InvalidRequestException("invalid view: " + view); + LibraryQuery query; + query.path = optionalParam("path", std::string()); + query.search = optionalParam("query", std::string()); - return Response::json({{"libraryItems", player_->getLibraryItems(query, range, columnsQuery.get())}}); + return Response::json({{"libraryNodes", player_->getLibraryNodes(query, range, columnsQuery.get())}}); } ResponsePtr LibraryController::addItems() { settings_->ensurePermissions(ApiPermissions::CHANGE_PLAYLISTS); - if (!player_->getLibraryInfo().supported) + if (!player_->supportsLibrary()) return notSupportedResponse(); LibraryItemQuery query; @@ -101,6 +97,7 @@ void LibraryController::defineRoutes( routes.get("info", &LibraryController::getInfo); routes.get("items/:range", &LibraryController::getItems); + routes.get("browse/:range", &LibraryController::browse); routes.post("items/add", ControllerAction(&LibraryController::addItems)); } diff --git a/cpp/server/library_controller.hpp b/cpp/server/library_controller.hpp index 10f4b4f8..68a85932 100644 --- a/cpp/server/library_controller.hpp +++ b/cpp/server/library_controller.hpp @@ -20,6 +20,7 @@ class LibraryController : public ControllerBase ResponsePtr getInfo(); ResponsePtr getItems(); + ResponsePtr browse(); ResponsePtr addItems(); static void defineRoutes(Router* router, WorkQueue* workQueue, Player* player, SettingsDataPtr settings); diff --git a/cpp/server/player_api.hpp b/cpp/server/player_api.hpp index efd7a081..dd34b200 100644 --- a/cpp/server/player_api.hpp +++ b/cpp/server/player_api.hpp @@ -350,8 +350,6 @@ struct LibraryNodesResult // Folder to navigate up to, only meaningful when hasParent is set std::string parentPath; bool hasParent = false; - - std::string pathSeparator; }; class PlayerOption @@ -646,6 +644,11 @@ class Player // Media library API + virtual bool supportsLibrary() + { + return false; + } + virtual LibraryInfo getLibraryInfo() { return LibraryInfo(); diff --git a/cpp/server/player_api_json.cpp b/cpp/server/player_api_json.cpp index f184e2ff..4775c5c5 100644 --- a/cpp/server/player_api_json.cpp +++ b/cpp/server/player_api_json.cpp @@ -217,7 +217,6 @@ void to_json(Json& json, const LibraryNodesResult& value) json["totalCount"] = value.totalCount; json["items"] = value.items; json["path"] = value.path; - json["pathSeparator"] = value.pathSeparator; if (value.hasParent) json["parentPath"] = value.parentPath; diff --git a/docs/player-api.yml b/docs/player-api.yml index 8db058cf..3e92f9d8 100644 --- a/docs/player-api.yml +++ b/docs/player-api.yml @@ -772,23 +772,6 @@ paths: type: array items: type: string - - name: view - in: query - description: > - How to present the media library: - "flat" returns a paged list of tracks, - "folders" returns children of a single folder (subfolders first, then tracks) - schema: - type: string - enum: [flat, folders] - default: flat - - name: path - in: query - description: > - Folder to list children of, as returned in the "path" property of a folder item. - Only used when view is "folders", empty or missing means top level - schema: - type: string - name: query in: query description: Search query to filter items, uses player query syntax @@ -796,9 +779,7 @@ paths: type: string - name: sort in: query - description: > - Title formatting expression to sort items by, defaults to file path. - Not used when view is "folders", where items are sorted by name + description: Title formatting expression to sort items by, defaults to file path schema: type: string - name: desc @@ -816,6 +797,54 @@ paths: 501: description: Media library is not supported by current player content: {} + /library/browse/{range}: + get: + tags: + - library + summary: Browse media library as folder structure + operationId: browseLibrary + description: > + Returns children of a single folder: subfolders first, then tracks. + Items are sorted by name + parameters: + - name: range + in: path + description: Item range in form offset:count + required: true + 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/BrowseLibraryResponse' + 501: + description: Media library is not supported by current player + content: {} /library/items/add: post: tags: @@ -1280,15 +1309,16 @@ components: description: > Folder to navigate up to, pass it back as "path". Absent when already at top level - pathSeparator: - type: string GetLibraryItemsResponse: type: object properties: libraryItems: - oneOf: - - $ref: '#/components/schemas/PlaylistItemsResult' - - $ref: '#/components/schemas/LibraryNodesResult' + $ref: '#/components/schemas/PlaylistItemsResult' + BrowseLibraryResponse: + type: object + properties: + libraryNodes: + $ref: '#/components/schemas/LibraryNodesResult' UpdatePlaylistsRequest: type: object properties: diff --git a/js/api_tests/src/library_api_tests.js b/js/api_tests/src/library_api_tests.js index 853d8787..f79676eb 100644 --- a/js/api_tests/src/library_api_tests.js +++ b/js/api_tests/src/library_api_tests.js @@ -49,7 +49,6 @@ describe('library api', () => { // Top level has nowhere to navigate up to assert.equal(result.path, ''); assert.equal(result.parentPath, undefined); - assert.ok(result.pathSeparator); for (const item of result.items) { @@ -80,12 +79,12 @@ describe('library api', () => { assert.equal(items.totalCount, 0); }); - test('reject unknown view', async () => { + test('browse requires supported player', async () => { const response = await client.handler.axios.get( - '/api/library/items/0:10', - { params: { columns: ['%title%'], view: 'nonsense' }, validateStatus: () => true }); + '/api/library/browse/0:10', + { params: { columns: ['%title%'] }, validateStatus: () => true }); - assert.equal(response.status, isSupported ? 400 : 501); + assert.equal(response.status, isSupported ? 200 : 501); }); test('get library items with query', async () => { diff --git a/js/client/src/player_client.js b/js/client/src/player_client.js index 99febef8..ef16f354 100644 --- a/js/client/src/player_client.js +++ b/js/client/src/player_client.js @@ -283,9 +283,11 @@ export default class PlayerClient return this.get(url, Object.assign({ columns }, options)).then(r => r.libraryItems); } - browseLibrary(path, columns, range) + browseLibrary(path, columns, range, options) { - return this.getLibraryItems(columns, range, { view: 'folders', path }); + const { offset, count } = parseRange(range); + const url = `api/library/browse/${offset}:${count}`; + return this.get(url, Object.assign({ columns, path }, options)).then(r => r.libraryNodes); } addLibraryItems(plref, options)