diff --git a/NEWS b/NEWS
index db8cc04f8..015cffde2 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,7 @@ ver 0.24 (not yet released)
 * protocol
   - new command "searchcount" (case-insensitive "count")
   - "playlistfind"/"playlistsearch" have "sort" and "window" parameters
+  - allow range in "playlistmove"
   - "save" can append to or replace an existing playlist
   - filter "prio" (for "playlistfind"/"playlistsearch")
   - limit "player" idle events to the current partition
diff --git a/doc/protocol.rst b/doc/protocol.rst
index 50207b735..f41d9488f 100644
--- a/doc/protocol.rst
+++ b/doc/protocol.rst
@@ -994,8 +994,9 @@ remote playlists (absolute URI with a supported scheme).
 
 .. _command_playlistmove:
 
-:command:`playlistmove {NAME} {FROM} {TO}`
-    Moves the song at position ``FROM`` in
+:command:`playlistmove {NAME} [{FROM} | {START:END}] {TO}`
+    Moves the song at position ``FROM`` or range of songs
+    at ``START:END`` [#since_0_24]_ in
     the playlist `NAME.m3u` to the
     position ``TO``.
 
diff --git a/src/PlaylistFile.cxx b/src/PlaylistFile.cxx
index e5dbeee3b..aa43569cc 100644
--- a/src/PlaylistFile.cxx
+++ b/src/PlaylistFile.cxx
@@ -296,18 +296,41 @@ PlaylistFileEditor::Insert(std::size_t i, const DetachedSong &song)
 	Insert(i, uri);
 }
 
-void
-PlaylistFileEditor::MoveIndex(unsigned src, unsigned dest)
+static PlaylistFileContents
+CutRange(PlaylistFileContents &src, RangeArg range) noexcept
 {
-	if (src >= contents.size() || dest >= contents.size())
+	PlaylistFileContents dest;
+	dest.reserve(range.Count());
+
+	const auto begin = std::next(src.begin(), range.start);
+	const auto end = std::next(src.begin(), range.end);
+
+	for (auto i = begin;  i != end; ++i)
+		dest.emplace_back(std::move(*i));
+
+	src.erase(begin, end);
+
+	return dest;
+}
+
+static void
+InsertRange(PlaylistFileContents &dest, PlaylistFileContents::iterator pos,
+	    PlaylistFileContents &&src) noexcept
+{
+	dest.reserve(dest.size() + src.size());
+
+	for (auto &i : src)
+		pos = std::next(dest.emplace(pos, std::move(i)));
+}
+
+void
+PlaylistFileEditor::MoveIndex(RangeArg src, unsigned dest)
+{
+	if (src.end > contents.size() || dest > contents.size() - src.Count())
 		throw PlaylistError(PlaylistResult::BAD_RANGE, "Bad range");
 
-	const auto src_i = std::next(contents.begin(), src);
-	auto value = std::move(*src_i);
-	contents.erase(src_i);
-
-	const auto dest_i = std::next(contents.begin(), dest);
-	contents.insert(dest_i, std::move(value));
+	auto tmp = CutRange(contents, src);
+	InsertRange(contents, std::next(contents.begin(), dest), std::move(tmp));
 }
 
 void
diff --git a/src/PlaylistFile.hxx b/src/PlaylistFile.hxx
index 2bbf97bfa..c905d8935 100644
--- a/src/PlaylistFile.hxx
+++ b/src/PlaylistFile.hxx
@@ -59,7 +59,7 @@ public:
 	void Insert(std::size_t i, const char *uri);
 	void Insert(std::size_t i, const DetachedSong &song);
 
-	void MoveIndex(unsigned src, unsigned dest);
+	void MoveIndex(RangeArg src, unsigned dest);
 	void RemoveIndex(unsigned i);
 	void RemoveRange(RangeArg range);
 
diff --git a/src/command/PlaylistCommands.cxx b/src/command/PlaylistCommands.cxx
index c3654d30f..7c756e756 100644
--- a/src/command/PlaylistCommands.cxx
+++ b/src/command/PlaylistCommands.cxx
@@ -202,10 +202,16 @@ handle_playlistmove([[maybe_unused]] Client &client,
 		    Request args, [[maybe_unused]] Response &r)
 {
 	const char *const name = args.front();
-	unsigned from = args.ParseUnsigned(1);
+
+	RangeArg from = args.ParseRange(1);
+	if (from.IsOpenEnded()) {
+		r.Error(ACK_ERROR_ARG, "Open-ended range not supported");
+		return CommandResult::ERROR;
+	}
+
 	unsigned to = args.ParseUnsigned(2);
 
-	if (from == to)
+	if (from.IsEmpty() || from.start == to)
 		/* this doesn't check whether the playlist exists, but
 		   what the hell.. */
 		return CommandResult::OK;