diff --git a/src/decoder/Bridge.cxx b/src/decoder/Bridge.cxx
index 314c067bf..25f169d41 100644
--- a/src/decoder/Bridge.cxx
+++ b/src/decoder/Bridge.cxx
@@ -401,6 +401,37 @@ DecoderBridge::OpenUri(const char *uri)
 	}
 }
 
+size_t
+DecoderBridge::Read(InputStream &is, void *buffer, size_t length)
+try {
+	assert(buffer != nullptr);
+	assert(dc.state == DecoderState::START ||
+	       dc.state == DecoderState::DECODE);
+
+	if (length == 0)
+		return 0;
+
+	ScopeLock lock(is.mutex);
+
+	while (true) {
+		if (CheckCancelRead())
+			return 0;
+
+		if (is.IsAvailable())
+			break;
+
+		is.cond.wait(is.mutex);
+	}
+
+	size_t nbytes = is.Read(buffer, length);
+	assert(nbytes > 0 || is.IsEOF());
+
+	return nbytes;
+} catch (const std::runtime_error &e) {
+	error = std::current_exception();
+	return 0;
+}
+
 void
 DecoderBridge::SubmitTimestamp(double t)
 {
diff --git a/src/decoder/Bridge.hxx b/src/decoder/Bridge.hxx
index 49d810633..7203ce28a 100644
--- a/src/decoder/Bridge.hxx
+++ b/src/decoder/Bridge.hxx
@@ -140,6 +140,7 @@ public:
 	uint64_t GetSeekFrame() override;
 	void SeekError() override;
 	InputStreamPtr OpenUri(const char *uri) override;
+	size_t Read(InputStream &is, void *buffer, size_t length) override;
 	void SubmitTimestamp(double t) override;
 	DecoderCommand SubmitData(InputStream *is,
 				  const void *data, size_t length,
diff --git a/src/decoder/Client.hxx b/src/decoder/Client.hxx
index d77f2ed8e..b0e1af0e0 100644
--- a/src/decoder/Client.hxx
+++ b/src/decoder/Client.hxx
@@ -98,6 +98,17 @@ public:
 	 */
 	virtual InputStreamPtr OpenUri(const char *uri) = 0;
 
+	/**
+	 * Blocking read from the input stream.
+	 *
+	 * @param is the input stream to read from
+	 * @param buffer the destination buffer
+	 * @param length the maximum number of bytes to read
+	 * @return the number of bytes read, or 0 if one of the following
+	 * occurs: end of file; error; command (like SEEK or STOP).
+	 */
+	virtual size_t Read(InputStream &is, void *buffer, size_t length) = 0;
+
 	/**
 	 * Sets the time stamp for the next data chunk [seconds].  The MPD
 	 * core automatically counts it up, and a decoder plugin only needs to
diff --git a/src/decoder/DecoderAPI.cxx b/src/decoder/DecoderAPI.cxx
index 6258b2a0a..1091690ce 100644
--- a/src/decoder/DecoderAPI.cxx
+++ b/src/decoder/DecoderAPI.cxx
@@ -19,8 +19,6 @@
 
 #include "config.h"
 #include "DecoderAPI.hxx"
-#include "DecoderControl.hxx"
-#include "Bridge.hxx"
 #include "input/InputStream.hxx"
 #include "Log.hxx"
 
@@ -30,44 +28,19 @@ size_t
 decoder_read(DecoderClient *client,
 	     InputStream &is,
 	     void *buffer, size_t length)
-try {
+{
 	assert(buffer != nullptr);
 
 	/* XXX don't allow client==nullptr */
-	if (client == nullptr)
+	if (client != nullptr)
+		return client->Read(is, buffer, length);
+
+	try {
 		return is.LockRead(buffer, length);
-
-	auto &bridge = *(DecoderBridge *)client;
-
-	assert(bridge.dc.state == DecoderState::START ||
-	       bridge.dc.state == DecoderState::DECODE);
-
-	if (length == 0)
-		return 0;
-
-	ScopeLock lock(is.mutex);
-
-	while (true) {
-		if (bridge.CheckCancelRead())
-			return 0;
-
-		if (is.IsAvailable())
-			break;
-
-		is.cond.wait(is.mutex);
-	}
-
-	size_t nbytes = is.Read(buffer, length);
-	assert(nbytes > 0 || is.IsEOF());
-
-	return nbytes;
-} catch (const std::runtime_error &e) {
-	auto *bridge = (DecoderBridge *)client;
-	if (bridge != nullptr)
-		bridge->error = std::current_exception();
-	else
+	} catch (const std::runtime_error &e) {
 		LogError(e);
-	return 0;
+		return 0;
+	}
 }
 
 bool
diff --git a/test/FakeDecoderAPI.cxx b/test/FakeDecoderAPI.cxx
index cd8d52cf2..9204835fe 100644
--- a/test/FakeDecoderAPI.cxx
+++ b/test/FakeDecoderAPI.cxx
@@ -80,51 +80,15 @@ FakeDecoder::OpenUri(const char *uri)
 }
 
 size_t
-decoder_read(gcc_unused DecoderClient *client,
-	     InputStream &is,
-	     void *buffer, size_t length)
+FakeDecoder::Read(InputStream &is, void *buffer, size_t length)
 {
 	try {
 		return is.LockRead(buffer, length);
-	} catch (const std::runtime_error &) {
+	} catch (const std::runtime_error &e) {
 		return 0;
 	}
 }
 
-bool
-decoder_read_full(DecoderClient *client, InputStream &is,
-		  void *_buffer, size_t size)
-{
-	uint8_t *buffer = (uint8_t *)_buffer;
-
-	while (size > 0) {
-		size_t nbytes = decoder_read(client, is, buffer, size);
-		if (nbytes == 0)
-			return false;
-
-		buffer += nbytes;
-		size -= nbytes;
-	}
-
-	return true;
-}
-
-bool
-decoder_skip(DecoderClient *client, InputStream &is, size_t size)
-{
-	while (size > 0) {
-		char buffer[1024];
-		size_t nbytes = decoder_read(client, is, buffer,
-					     std::min(sizeof(buffer), size));
-		if (nbytes == 0)
-			return false;
-
-		size -= nbytes;
-	}
-
-	return true;
-}
-
 void
 FakeDecoder::SubmitTimestamp(gcc_unused double t)
 {
diff --git a/test/FakeDecoderAPI.hxx b/test/FakeDecoderAPI.hxx
index 5b9256a74..2850e9f9a 100644
--- a/test/FakeDecoderAPI.hxx
+++ b/test/FakeDecoderAPI.hxx
@@ -40,6 +40,7 @@ struct FakeDecoder final : DecoderClient {
 	uint64_t GetSeekFrame() override;
 	void SeekError() override;
 	InputStreamPtr OpenUri(const char *uri) override;
+	size_t Read(InputStream &is, void *buffer, size_t length) override;
 	void SubmitTimestamp(double t) override;
 	DecoderCommand SubmitData(InputStream *is,
 				  const void *data, size_t length,