diff --git a/cpp/server/artwork_controller.cpp b/cpp/server/artwork_controller.cpp index 73edf736..c56c0a9b 100644 --- a/cpp/server/artwork_controller.cpp +++ b/cpp/server/artwork_controller.cpp @@ -57,7 +57,9 @@ ResponsePtr ArtworkController::getLibraryArtwork() item.path = param("path"); item.subsong = optionalParam("subsong", -1); - auto responseFuture = player_->fetchLibraryArtwork(item).then( + auto preferFolderImage = optionalParam("folderImage", false); + + auto responseFuture = player_->fetchLibraryArtwork(item, preferFolderImage).then( boost::launch::sync, [this](boost::unique_future resultFuture) { auto result = resultFuture.get(); return getResponse(&result); diff --git a/cpp/server/foobar2000/player.hpp b/cpp/server/foobar2000/player.hpp index ff87627a..2eb74cc3 100644 --- a/cpp/server/foobar2000/player.hpp +++ b/cpp/server/foobar2000/player.hpp @@ -112,13 +112,17 @@ class PlayerImpl final : public Player LibraryNodesResult getLibraryNodes( const LibraryQuery& query, const Range& range, ColumnsQuery* columns) override; + LibraryGroupsResult getLibraryGroups( + const LibraryGroupQuery& 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 fetchLibraryArtwork( + const LibraryItemRef& item, bool preferFolderImage) override; boost::unique_future fetchCurrentArtwork() override; boost::unique_future fetchArtwork(const ArtworkQuery& query) override; diff --git a/cpp/server/foobar2000/player_library.cpp b/cpp/server/foobar2000/player_library.cpp index 7a908b09..4fc31243 100644 --- a/cpp/server/foobar2000/player_library.cpp +++ b/cpp/server/foobar2000/player_library.cpp @@ -54,6 +54,18 @@ std::string joinNodePath(const std::string& prefix, const std::string& name) return prefix.empty() ? name : prefix + PATH_SEPARATOR + name; } +// Orders names the way the platform does: case-insensitive, embedded numbers compared by value. +// Names that still compare equal (e.g. differ only in case) are kept distinct and ordered byte-wise, +// otherwise such folders would be merged together +struct NameLess +{ + bool operator()(const std::string& left, const std::string& right) const + { + auto result = pfc::sysNaturalSortCompareI(left.c_str(), right.c_str()); + return result != 0 ? result < 0 : left < right; + } +}; + using NodeItem = std::pair; // Single file may hold several tracks (cue sheets), keep such tracks in subsong order @@ -61,7 +73,7 @@ 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 NameLess()(left.first, right.first); return left.second->get_location().get_subsong() < right.second->get_location().get_subsong(); }); @@ -94,6 +106,54 @@ bool matchesRef(const LibraryItemRef& ref, const std::string& itemPath, const me return isSubpath(itemPath, path); } +// Separates levels in grouping pattern output, same as Album List views +constexpr char GROUP_SEPARATOR = '|'; + +// Shown for empty level values, same as for missing fields in title formatting, +// this also keeps any group distinguishable from the top level +constexpr char MISSING_VALUE[] = "?"; + +std::vector splitLevels(const std::string& text) +{ + std::vector levels; + size_t start = 0; + + while (true) + { + auto end = text.find(GROUP_SEPARATOR, start); + auto level = text.substr(start, end == std::string::npos ? std::string::npos : end - start); + levels.emplace_back(level.empty() ? std::string(MISSING_VALUE) : std::move(level)); + + if (end == std::string::npos) + return levels; + + start = end + 1; + } +} + +std::string joinLevels(std::vector::const_iterator begin, std::vector::const_iterator end) +{ + std::string result; + + for (auto it = begin; it != end; ++it) + { + if (it != begin) + result += GROUP_SEPARATOR; + + result += *it; + } + + return result; +} + +struct GroupTrack +{ + std::string label; + std::string path; + std::string sortKey; + metadb_handle_ptr item; +}; + // Item locations are prefixed with a scheme, plain file system path is what artwork lookup needs std::string getAbsolutePath(const metadb_handle_ptr& item) { @@ -108,8 +168,9 @@ std::string getAbsolutePath(const metadb_handle_ptr& item) 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 +// Folders may hold an image file that represents the folder itself. +// This is not how the player resolves artwork (it uses configurable per track patterns), +// so it is only used when explicitly requested std::string findFolderArtwork(const std::string& folderPath) { static const char* const names[] = {"folder", "cover", "front", "album", "artwork"}; @@ -182,6 +243,29 @@ bool endsWithNodePath(const std::string& absolutePath, const std::string& nodePa return true; } +// Absolute path of the folder an item reference points to, derived from a track below it. +// Empty when the reference points to a file or to the whole library +std::string getFolderPath(const LibraryItemRef& ref, const metadb_handle_ptr& firstItem) +{ + auto path = normalizeNodePath(ref.path); + if (path.empty()) + return std::string(); + + pfc::string8 buffer; + auto nodePath = getNodePath(library_manager::get(), firstItem, &buffer); + + if (nodePath.length() <= path.length()) + return std::string(); + + auto absolutePath = getAbsolutePath(firstItem); + auto suffixLength = nodePath.length() - path.length(); + + if (absolutePath.length() <= suffixLength || !endsWithNodePath(absolutePath, nodePath)) + return std::string(); + + return absolutePath.substr(0, absolutePath.length() - suffixLength); +} + class ItemCounter : public library_manager::enum_callback { public: @@ -402,7 +486,8 @@ void PlayerImpl::addLibraryItems( } } -boost::unique_future PlayerImpl::fetchLibraryArtwork(const LibraryItemRef& item) +boost::unique_future PlayerImpl::fetchLibraryArtwork( + const LibraryItemRef& item, bool preferFolderImage) { LibraryItemQuery query; query.items.emplace_back(item); @@ -414,20 +499,13 @@ boost::unique_future PlayerImpl::fetchLibraryArtwork(const Librar 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()) + if (preferFolderImage) { - auto absolutePath = getAbsolutePath(firstItem); - auto suffixLength = nodePath.length() - path.length(); + auto folderPath = getFolderPath(item, firstItem); - if (absolutePath.length() > suffixLength && endsWithNodePath(absolutePath, nodePath)) + if (!folderPath.empty()) { - auto folderPath = absolutePath.substr(0, absolutePath.length() - suffixLength); auto artwork = findFolderArtwork(folderPath); if (!artwork.empty()) @@ -456,7 +534,7 @@ LibraryNodesResult PlayerImpl::getLibraryNodes( auto prefix = normalizeNodePath(query.path); auto childOffset = prefix.empty() ? 0 : prefix.length() + 1; - std::map folders; + std::map folders; std::vector files; pfc::string8 buffer; @@ -545,5 +623,152 @@ LibraryNodesResult PlayerImpl::getLibraryNodes( return nodesResult; } +LibraryGroupsResult PlayerImpl::getLibraryGroups( + const LibraryGroupQuery& query, const Range& range, ColumnsQuery* columns) +{ + auto queryImpl = dynamic_cast(columns); + if (!queryImpl) + throw std::logic_error("ColumnsQueryImpl is required"); + + titleformat_object::ptr groupBy; + if (!titleFormatCompiler_->compile(groupBy, query.groupBy.c_str())) + throw InvalidRequestException("invalid format expression: " + query.groupBy); + + titleformat_object::ptr sortBy; + if (!query.sortBy.empty() && !titleFormatCompiler_->compile(sortBy, query.sortBy.c_str())) + throw InvalidRequestException("invalid format expression: " + query.sortBy); + + auto current = query.group.empty() ? std::vector() : splitLevels(query.group); + auto depth = current.size(); + + auto libraryManager = library_manager::get(); + + metadb_handle_list items; + libraryManager->get_all_items(items); + + if (!query.search.empty()) + filterItems(&items, query.search); + + std::map groups; + std::vector tracks; + pfc::string8 buffer; + + for (t_size i = 0; i < items.get_count(); i++) + { + const auto& item = items[i]; + + item->format_title(nullptr, buffer, groupBy, nullptr); + auto levels = splitLevels(std::string(buffer.get_ptr(), buffer.get_length())); + + // Item belongs below current node only when it has more levels and all selected values match + if (levels.size() <= depth || !std::equal(current.begin(), current.end(), levels.begin())) + continue; + + // Last level of an item is the label of the track itself + if (levels.size() == depth + 1) + { + GroupTrack track; + track.label = std::move(levels[depth]); + track.path = getNodePath(libraryManager, item, &buffer); + track.item = item; + tracks.emplace_back(std::move(track)); + } + else + { + groups[levels[depth]]++; + } + } + + for (auto& track : tracks) + { + if (sortBy.is_valid()) + { + track.item->format_title(nullptr, buffer, sortBy, nullptr); + track.sortKey.assign(buffer.get_ptr(), buffer.get_length()); + } + else + { + track.sortKey = track.label; + } + } + + // Path and subsong make the order deterministic, so that paging is stable + std::sort(tracks.begin(), tracks.end(), [](const GroupTrack& left, const GroupTrack& right) { + if (left.sortKey != right.sortKey) + return NameLess()(left.sortKey, right.sortKey); + + if (left.path != right.path) + return left.path < right.path; + + return left.item->get_location().get_subsong() < right.item->get_location().get_subsong(); + }); + + if (query.sortDescending) + std::reverse(tracks.begin(), tracks.end()); + + auto groupPath = joinLevels(current.begin(), current.end()); + + std::vector nodes; + std::vector handles; + + nodes.reserve(groups.size() + tracks.size()); + handles.reserve(groups.size() + tracks.size()); + + for (auto& group : groups) + { + LibraryNodeInfo node; + node.isFolder = true; + node.name = group.first; + node.group = groupPath.empty() ? group.first : groupPath + GROUP_SEPARATOR + group.first; + node.itemCount = group.second; + nodes.emplace_back(std::move(node)); + handles.emplace_back(); + } + + for (auto& track : tracks) + { + LibraryNodeInfo node; + node.name = std::move(track.label); + node.path = std::move(track.path); + node.subsong = static_cast(track.item->get_location().get_subsong()); + nodes.emplace_back(std::move(node)); + handles.emplace_back(track.item); + } + + 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])); + } + } + + LibraryGroupsResult groupsResult( + static_cast(offset), + static_cast(totalCount), + std::move(result)); + + groupsResult.group = groupPath; + + if (depth > 0) + { + groupsResult.hasParent = true; + groupsResult.parentGroup = joinLevels(current.begin(), current.end() - 1); + } + + return groupsResult; +} + } } diff --git a/cpp/server/library_controller.cpp b/cpp/server/library_controller.cpp index 8f0b8225..49e1ffbc 100644 --- a/cpp/server/library_controller.cpp +++ b/cpp/server/library_controller.cpp @@ -30,9 +30,19 @@ ResponsePtr LibraryController::notSupportedResponse() Range LibraryController::readRange() { + auto range = optionalParam("range"); + // 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())); + if (!range) + return Range(0, std::numeric_limits::max()); + + // Bare number means a single item elsewhere in the API, which is too easy + // to mistake for an item count here, so count is required + if (range->find(':') == std::string::npos) + throw InvalidRequestException("range should be in form offset:count"); + + return param("range"); } ResponsePtr LibraryController::getItems() @@ -64,6 +74,26 @@ ResponsePtr LibraryController::getItemsByPath() return Response::json({{"libraryNodes", player_->getLibraryNodes(query, readRange(), columnsQuery.get())}}); } +ResponsePtr LibraryController::getItemsByColumns() +{ + if (!player_->supportsLibrary()) + return notSupportedResponse(); + + auto columnsQuery = player_->createColumnsQuery(param>("columns")); + + LibraryGroupQuery query; + query.groupBy = param("groupBy"); + query.group = optionalParam("group", std::string()); + query.search = optionalParam("query", std::string()); + query.sortBy = optionalParam("sort", std::string()); + query.sortDescending = optionalParam("desc", false); + + if (query.groupBy.empty()) + throw InvalidRequestException("groupBy should not be empty"); + + return Response::json({{"libraryNodes", player_->getLibraryGroups(query, readRange(), columnsQuery.get())}}); +} + void LibraryController::defineRoutes(Router* router, WorkQueue* workQueue, Player* player) { auto routes = router->defineRoutes(); @@ -75,6 +105,7 @@ void LibraryController::defineRoutes(Router* router, WorkQueue* workQueue, Playe routes.get("info", &LibraryController::getInfo); routes.get("items", &LibraryController::getItems); routes.get("items/by-path", &LibraryController::getItemsByPath); + routes.get("items/by-columns", &LibraryController::getItemsByColumns); } } diff --git a/cpp/server/library_controller.hpp b/cpp/server/library_controller.hpp index b3126e68..1c0bb7af 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 getItemsByPath(); + ResponsePtr getItemsByColumns(); static void defineRoutes(Router* router, WorkQueue* workQueue, Player* player); diff --git a/cpp/server/player_api.hpp b/cpp/server/player_api.hpp index 0786801f..881325f9 100644 --- a/cpp/server/player_api.hpp +++ b/cpp/server/player_api.hpp @@ -335,6 +335,10 @@ struct LibraryNodeInfo bool isFolder = false; std::string name; std::string path; + + // Grouping node, set for groups produced by a title formatting pattern + std::string group; + int32_t itemCount = 0; int32_t subsong = 0; std::vector columns; @@ -396,6 +400,51 @@ struct LibraryNodesResult bool hasParent = false; }; +// Grouping structure defined the same way as Album List views: +// "|" in the pattern output separates levels, the last level is the label of the track itself +struct LibraryGroupQuery +{ + LibraryGroupQuery() = default; + LibraryGroupQuery(LibraryGroupQuery&&) = default; + LibraryGroupQuery& operator=(LibraryGroupQuery&&) = default; + + std::string groupBy; + + // Current node: values of selected levels joined with "|", empty for top level + std::string group; + + std::string search; + std::string sortBy; + bool sortDescending = false; +}; + +struct LibraryGroupsResult +{ + LibraryGroupsResult( + int32_t offsetVal, + int32_t totalCountVal, + std::vector itemsVal) + : offset(offsetVal), + totalCount(totalCountVal), + items(std::move(itemsVal)) + { + } + + LibraryGroupsResult(LibraryGroupsResult&&) = default; + LibraryGroupsResult& operator=(LibraryGroupsResult&&) = default; + + int32_t offset; + int32_t totalCount; + std::vector items; + + // Node these items belong to, empty at top level + std::string group; + + // Node to navigate up to, only meaningful when hasParent is set + std::string parentGroup; + bool hasParent = false; +}; + class PlayerOption { public: @@ -708,6 +757,16 @@ class Player throw std::logic_error("media library is not supported by this player"); } + virtual LibraryGroupsResult getLibraryGroups( + const LibraryGroupQuery& 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) { @@ -732,9 +791,13 @@ class Player throw std::logic_error("media library is not supported by this player"); } - virtual boost::unique_future fetchLibraryArtwork(const LibraryItemRef& item) + // When item refers to a folder and preferFolderImage is set, + // image file stored in that folder is returned instead of artwork of the first track + virtual boost::unique_future fetchLibraryArtwork( + const LibraryItemRef& item, bool preferFolderImage) { (void) item; + (void) preferFolderImage; throw std::logic_error("media library is not supported by this player"); } diff --git a/cpp/server/player_api_json.cpp b/cpp/server/player_api_json.cpp index 7e8cf962..58f7de4d 100644 --- a/cpp/server/player_api_json.cpp +++ b/cpp/server/player_api_json.cpp @@ -230,7 +230,12 @@ void to_json(Json& json, const LibraryNodeInfo& value) { json["type"] = value.isFolder ? "D" : "F"; json["name"] = value.name; - json["path"] = value.path; + + if (!value.path.empty()) + json["path"] = value.path; + + if (!value.group.empty()) + json["group"] = value.group; if (value.isFolder) { @@ -254,6 +259,17 @@ void to_json(Json& json, const LibraryNodesResult& value) json["parentPath"] = value.parentPath; } +void to_json(Json& json, const LibraryGroupsResult& value) +{ + json["offset"] = value.offset; + json["totalCount"] = value.totalCount; + json["items"] = value.items; + json["group"] = value.group; + + if (value.hasParent) + json["parentGroup"] = value.parentGroup; +} + 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 5c5a82b8..bfd05491 100644 --- a/cpp/server/player_api_json.hpp +++ b/cpp/server/player_api_json.hpp @@ -23,6 +23,7 @@ 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 LibraryGroupsResult& 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 722b16c4..3e693947 100644 --- a/cpp/server/playlists_controller.cpp +++ b/cpp/server/playlists_controller.cpp @@ -195,6 +195,10 @@ ResponsePtr PlaylistsController::addItemsFromLibrary() if (auto items = optionalBodyParam>("items")) query.items = std::move(*items); + // Missing both is most likely a client mistake, adding everything has to be requested explicitly + if (query.items.empty() && query.search.empty()) + throw InvalidRequestException("items or query is required, use [\"\"] as items to add everything"); + auto options = AddItemsOptions::NONE; if (optionalParam("replace", false)) diff --git a/docs/player-api.yml b/docs/player-api.yml index 8ab3eaa8..eb61e6b3 100644 --- a/docs/player-api.yml +++ b/docs/player-api.yml @@ -761,7 +761,8 @@ paths: 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 + naturally ordered, so paging is optional and all items are returned by default. + Offset without count is rejected schema: type: string - name: columns @@ -807,11 +808,13 @@ paths: operationId: getLibraryItemsByPath description: > Returns children of a single folder: subfolders first, then tracks. - Items are sorted by name + Items are sorted by name, case-insensitively and with embedded numbers compared by value parameters: - name: range in: query - description: Item range in form offset:count, all items are returned by default + description: > + Item range in form offset:count, all items are returned by default. + Offset without count is rejected schema: type: string - name: columns @@ -846,6 +849,75 @@ paths: 501: description: Media library is not supported by current player content: {} + /library/items/by-columns: + get: + tags: + - library + summary: Get media library items grouped by title formatting + operationId: getLibraryItemsByColumns + description: > + Returns children of a single node of a grouping structure defined by a title formatting + pattern, the same way Album List views are defined: "|" in the pattern output separates + levels, and the last level is the label of the track itself. + Groups come first sorted by name, then tracks + parameters: + - name: groupBy + in: query + description: > + Title formatting pattern, e.g. "%album artist%|%album%|%tracknumber%. %title%". + Empty level values are shown as "?" + required: true + schema: + type: string + - name: group + in: query + description: > + Node to list children of, as returned in the "group" property of a group item: + values of selected levels joined with "|". Empty or missing means top level + schema: + type: string + - name: range + in: query + description: > + Item range in form offset:count, all items are returned by default. + Offset without count is rejected + 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: 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 tracks by, defaults to track label + schema: + type: string + - name: desc + in: query + description: Sort tracks in descending order, groups are always sorted by name + schema: + type: boolean + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetLibraryItemsByColumnsResponse' + 501: + description: Media library is not supported by current player + content: {} /playlists/{playlistId}/items/add-from-library: post: tags: @@ -895,6 +967,16 @@ paths: When omitted artwork of the first matching track is returned schema: type: integer + - name: folderImage + in: query + description: > + When path points to a folder, return an image file stored in that folder + (folder, cover, front, album or artwork with a common image extension) if one exists. + By default artwork of the first track is returned, resolved the same way as + elsewhere in the player + schema: + type: boolean + default: false responses: 200: description: Success @@ -1259,7 +1341,9 @@ components: type: array description: > Items to add, as returned by media library queries. - When omitted everything matching query is added + When omitted everything matching query is added. + Request without both items and query is rejected, + use [""] as items to add the entire media library items: $ref: '#/components/schemas/LibraryItemRef' query: @@ -1292,6 +1376,11 @@ components: path: type: string description: Path relative to media library folders, pass back as "path" to list children + group: + type: string + description: > + Grouping node, for groups returned by title formatting grouping, + pass back as "group" to list children itemCount: type: integer description: Number of tracks under this folder, folders only @@ -1360,6 +1449,30 @@ components: properties: libraryNodes: $ref: '#/components/schemas/LibraryNodesResult' + LibraryGroupsResult: + type: object + properties: + offset: + type: integer + totalCount: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/LibraryNodeInfo' + group: + type: string + description: Node these items belong to, empty at top level + parentGroup: + type: string + description: > + Node to navigate up to, pass it back as "group". + Absent when already at top level + GetLibraryItemsByColumnsResponse: + type: object + properties: + libraryNodes: + $ref: '#/components/schemas/LibraryGroupsResult' 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 c65873a1..ff522359 100644 --- a/js/api_tests/src/library_api_tests.js +++ b/js/api_tests/src/library_api_tests.js @@ -63,11 +63,14 @@ describe('library api', () => { }); 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 }); + for (const folderImage of [false, true]) + { + const response = await client.handler.axios.get( + '/api/artwork/library', + { params: { path: 'no/such/folder', folderImage }, validateStatus: () => true }); - assert.equal(response.status, isSupported ? 404 : 501); + assert.equal(response.status, isSupported ? 404 : 501); + } }); test('add library items to playlist', async () => { @@ -77,7 +80,7 @@ describe('library api', () => { 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, {}); + await client.addPlaylistItemsFromLibrary(playlist.id, { items: [''] }); // Explicitly referenced items resolve to nothing for the same reason await client.addPlaylistItemsFromLibrary( @@ -87,6 +90,53 @@ describe('library api', () => { assert.equal(items.totalCount, 0); }); + test('reject add without items or query', async () => { + const response = await client.handler.axios.post( + '/api/playlists/0/items/add-from-library', {}, { validateStatus: () => true }); + + assert.equal(response.status, isSupported ? 400 : 501); + }); + + test('reject range without count', async () => { + const response = await client.handler.axios.get( + '/api/library/items', + { params: { columns: ['%title%'], range: '100' }, validateStatus: () => true }); + + assert.equal(response.status, isSupported ? 400 : 501); + }); + + test('browse by columns', async () => { + if (!isSupported) + { + const response = await client.handler.axios.get( + '/api/library/items/by-columns', + { params: { columns: ['%title%'], groupBy: '%artist%|%title%' }, validateStatus: () => true }); + + assert.equal(response.status, 501); + return; + } + + const root = await client.getLibraryItemsByColumns('%artist%|%title%', '', ['%title%']); + + assert.equal(root.offset, 0); + assert.equal(root.group, ''); + assert.equal(root.parentGroup, undefined); + assert.ok(Array.isArray(root.items)); + + const nested = await client.getLibraryItemsByColumns('%artist%|%title%', 'Some Artist', ['%title%']); + + assert.equal(nested.group, 'Some Artist'); + assert.equal(nested.parentGroup, ''); + }); + + test('browse by columns requires grouping pattern', async () => { + const response = await client.handler.axios.get( + '/api/library/items/by-columns', + { params: { columns: ['%title%'] }, validateStatus: () => true }); + + assert.equal(response.status, isSupported ? 400 : 501); + }); + test('browse by path requires supported player', async () => { const response = await client.handler.axios.get( '/api/library/items/by-path', diff --git a/js/api_tests/src/permissions_tests.js b/js/api_tests/src/permissions_tests.js index 4c3baaac..73b1a069 100644 --- a/js/api_tests/src/permissions_tests.js +++ b/js/api_tests/src/permissions_tests.js @@ -37,7 +37,7 @@ describe('permissions', () => { }); test('add playlist items from library', async () => { - const response = await post('/api/playlists/0/items/add-from-library', { path: '' }); + const response = await post('/api/playlists/0/items/add-from-library', { items: [''] }); assert.equal(response.status, 403); }); diff --git a/js/client/src/player_client.js b/js/client/src/player_client.js index ca75adde..e630f0ce 100644 --- a/js/client/src/player_client.js +++ b/js/client/src/player_client.js @@ -289,18 +289,27 @@ export default class PlayerClient return this.get('api/library/items/by-path', params).then(r => r.libraryNodes); } + getLibraryItemsByColumns(groupBy, group, columns, range, options) + { + const params = Object.assign({ groupBy, group, columns, range: formatOptionalRange(range) }, options); + return this.get('api/library/items/by-columns', params).then(r => r.libraryNodes); + } + addPlaylistItemsFromLibrary(plref, options) { return this.post(`api/playlists/${plref}/items/add-from-library`, options); } - getLibraryArtworkUrl(path, subsong) + getLibraryArtworkUrl(path, subsong, options = {}) { const params = new URLSearchParams({ path }); if (subsong !== undefined) params.set('subsong', subsong); + if (options.folderImage) + params.set('folderImage', 'true'); + return `api/artwork/library?${params}`; }