diff --git a/src/lib/curl/Adapter.cxx b/src/lib/curl/Adapter.cxx
new file mode 100644
index 000000000..e41587bff
--- /dev/null
+++ b/src/lib/curl/Adapter.cxx
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2008-2021 Max Kellermann <max.kellermann@gmail.com>
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the
+ * distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE
+ * FOUNDATION OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "Adapter.hxx"
+#include "Easy.hxx"
+#include "Handler.hxx"
+#include "util/CharUtil.hxx"
+#include "util/RuntimeError.hxx"
+#include "util/StringStrip.hxx"
+#include "util/StringView.hxx"
+
+#include <algorithm>
+#include <cassert>
+
+void
+CurlResponseHandlerAdapter::Install(CurlEasy &easy)
+{
+	assert(state == State::UNINITIALISED);
+
+	error_buffer[0] = 0;
+	easy.SetErrorBuffer(error_buffer);
+
+	easy.SetHeaderFunction(_HeaderFunction, this);
+	easy.SetWriteFunction(WriteFunction, this);
+
+	curl = easy.Get();
+
+	state = State::HEADERS;
+}
+
+void
+CurlResponseHandlerAdapter::FinishHeaders()
+{
+	assert(state >= State::HEADERS);
+
+	if (state != State::HEADERS)
+		return;
+
+	state = State::BODY;
+
+	long status = 0;
+	curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
+
+	handler.OnHeaders(status, std::move(headers));
+}
+
+void
+CurlResponseHandlerAdapter::FinishBody()
+{
+	FinishHeaders();
+
+	if (state != State::BODY)
+		return;
+
+	state = State::CLOSED;
+	handler.OnEnd();
+}
+
+void
+CurlResponseHandlerAdapter::Done(CURLcode result) noexcept
+{
+	try {
+		if (result != CURLE_OK) {
+			StripRight(error_buffer);
+			const char *msg = error_buffer;
+			if (*msg == 0)
+				msg = curl_easy_strerror(result);
+			throw FormatRuntimeError("CURL failed: %s", msg);
+		}
+
+		FinishBody();
+	} catch (...) {
+		state = State::CLOSED;
+		handler.OnError(std::current_exception());
+	}
+}
+
+[[gnu::pure]]
+static bool
+IsResponseBoundaryHeader(StringView s) noexcept
+{
+	return s.size > 5 && (s.StartsWith("HTTP/") ||
+			      /* the proprietary "ICY 200 OK" is
+				 emitted by Shoutcast */
+			      s.StartsWith("ICY 2"));
+}
+
+inline void
+CurlResponseHandlerAdapter::HeaderFunction(StringView s) noexcept
+{
+	if (state > State::HEADERS)
+		return;
+
+	if (IsResponseBoundaryHeader(s)) {
+		/* this is the boundary to a new response, for example
+		   after a redirect */
+		headers.clear();
+		return;
+	}
+
+	const char *header = s.data;
+	const char *end = StripRight(header, header + s.size);
+
+	const char *value = s.Find(':');
+	if (value == nullptr)
+		return;
+
+	std::string name(header, value);
+	std::transform(name.begin(), name.end(), name.begin(),
+		       static_cast<char(*)(char)>(ToLowerASCII));
+
+	/* skip the colon */
+
+	++value;
+
+	/* strip the value */
+
+	value = StripLeft(value, end);
+	end = StripRight(value, end);
+
+	headers.emplace(std::move(name), std::string(value, end));
+}
+
+std::size_t
+CurlResponseHandlerAdapter::_HeaderFunction(char *ptr, std::size_t size,
+					    std::size_t nmemb,
+					    void *stream) noexcept
+{
+	CurlResponseHandlerAdapter &c = *(CurlResponseHandlerAdapter *)stream;
+
+	size *= nmemb;
+
+	c.HeaderFunction({ptr, size});
+	return size;
+}
+
+inline std::size_t
+CurlResponseHandlerAdapter::DataReceived(const void *ptr,
+					 std::size_t received_size) noexcept
+{
+	assert(received_size > 0);
+
+	try {
+		FinishHeaders();
+		handler.OnData({ptr, received_size});
+		return received_size;
+	} catch (CurlResponseHandler::Pause) {
+		return CURL_WRITEFUNC_PAUSE;
+	}
+
+}
+
+std::size_t
+CurlResponseHandlerAdapter::WriteFunction(char *ptr, std::size_t size,
+					  std::size_t nmemb,
+					  void *stream) noexcept
+{
+	CurlResponseHandlerAdapter &c = *(CurlResponseHandlerAdapter *)stream;
+
+	size *= nmemb;
+	if (size == 0)
+		return 0;
+
+	return c.DataReceived(ptr, size);
+}
diff --git a/src/lib/curl/Adapter.hxx b/src/lib/curl/Adapter.hxx
new file mode 100644
index 000000000..1d26f46e6
--- /dev/null
+++ b/src/lib/curl/Adapter.hxx
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2008-2021 Max Kellermann <max.kellermann@gmail.com>
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the
+ * distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE
+ * FOUNDATION OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef CURL_ADAPTER_HXX
+#define CURL_ADAPTER_HXX
+
+#include <curl/curl.h>
+
+#include <cstddef>
+#include <map>
+#include <string>
+
+struct StringView;
+class CurlEasy;
+class CurlResponseHandler;
+
+class CurlResponseHandlerAdapter {
+	CURL *curl;
+
+	CurlResponseHandler &handler;
+
+	std::multimap<std::string, std::string> headers;
+
+	/** error message provided by libcurl */
+	char error_buffer[CURL_ERROR_SIZE];
+
+	enum class State {
+		UNINITIALISED,
+		HEADERS,
+		BODY,
+		CLOSED,
+	} state = State::UNINITIALISED;
+
+public:
+	explicit CurlResponseHandlerAdapter(CurlResponseHandler &_handler) noexcept
+		:handler(_handler) {}
+
+	void Install(CurlEasy &easy);
+
+	void Done(CURLcode result) noexcept;
+
+private:
+	void FinishHeaders();
+	void FinishBody();
+
+	void HeaderFunction(StringView s) noexcept;
+
+	/** called by curl when a new header is available */
+	static std::size_t _HeaderFunction(char *ptr,
+					   std::size_t size, std::size_t nmemb,
+					   void *stream) noexcept;
+
+	std::size_t DataReceived(const void *ptr, std::size_t size) noexcept;
+
+	/** called by curl when new data is available */
+	static std::size_t WriteFunction(char *ptr,
+					 std::size_t size, std::size_t nmemb,
+					 void *stream) noexcept;
+};
+
+#endif
diff --git a/src/lib/curl/Request.cxx b/src/lib/curl/Request.cxx
index 6f041af7e..7cdd10b12 100644
--- a/src/lib/curl/Request.cxx
+++ b/src/lib/curl/Request.cxx
@@ -30,20 +30,15 @@
 #include "config.h"
 #include "Request.hxx"
 #include "Global.hxx"
-#include "Handler.hxx"
 #include "event/Call.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringStrip.hxx"
-#include "util/StringView.hxx"
-#include "util/CharUtil.hxx"
 #include "Version.h"
 
 #include <curl/curl.h>
 
-#include <algorithm>
 #include <cassert>
 
-#include <string.h>
+#include <stdio.h>
 
 CurlRequest::CurlRequest(CurlGlobal &_global, CurlEasy _easy,
 			 CurlResponseHandler &_handler)
@@ -67,16 +62,14 @@ CurlRequest::~CurlRequest() noexcept
 void
 CurlRequest::SetupEasy()
 {
-	error_buffer[0] = 0;
-
 	easy.SetPrivate((void *)this);
+
+	handler.Install(easy);
+
 	easy.SetUserAgent("Music Player Daemon " VERSION);
-	easy.SetHeaderFunction(_HeaderFunction, this);
-	easy.SetWriteFunction(WriteFunction, this);
 #if !defined(ANDROID) && !defined(_WIN32)
 	easy.SetOption(CURLOPT_NETRC, 1L);
 #endif
-	easy.SetErrorBuffer(error_buffer);
 	easy.SetNoProgress();
 	easy.SetNoSignal();
 	easy.SetConnectTimeout(10);
@@ -138,135 +131,10 @@ CurlRequest::Resume() noexcept
 	global.InvalidateSockets();
 }
 
-void
-CurlRequest::FinishHeaders()
-{
-	if (state != State::HEADERS)
-		return;
-
-	state = State::BODY;
-
-	long status = 0;
-	easy.GetInfo(CURLINFO_RESPONSE_CODE, &status);
-
-	handler.OnHeaders(status, std::move(headers));
-}
-
-void
-CurlRequest::FinishBody()
-{
-	FinishHeaders();
-
-	if (state != State::BODY)
-		return;
-
-	state = State::CLOSED;
-	handler.OnEnd();
-}
-
 void
 CurlRequest::Done(CURLcode result) noexcept
 {
 	Stop();
 
-	try {
-		if (result != CURLE_OK) {
-			StripRight(error_buffer);
-			const char *msg = error_buffer;
-			if (*msg == 0)
-				msg = curl_easy_strerror(result);
-			throw FormatRuntimeError("CURL failed: %s", msg);
-		}
-
-		FinishBody();
-	} catch (...) {
-		state = State::CLOSED;
-		handler.OnError(std::current_exception());
-	}
-}
-
-[[gnu::pure]]
-static bool
-IsResponseBoundaryHeader(StringView s) noexcept
-{
-	return s.size > 5 && (s.StartsWith("HTTP/") ||
-			      /* the proprietary "ICY 200 OK" is
-				 emitted by Shoutcast */
-			      s.StartsWith("ICY 2"));
-}
-
-inline void
-CurlRequest::HeaderFunction(StringView s) noexcept
-{
-	if (state > State::HEADERS)
-		return;
-
-	if (IsResponseBoundaryHeader(s)) {
-		/* this is the boundary to a new response, for example
-		   after a redirect */
-		headers.clear();
-		return;
-	}
-
-	const char *header = s.data;
-	const char *end = StripRight(header, header + s.size);
-
-	const char *value = s.Find(':');
-	if (value == nullptr)
-		return;
-
-	std::string name(header, value);
-	std::transform(name.begin(), name.end(), name.begin(),
-		       static_cast<char(*)(char)>(ToLowerASCII));
-
-	/* skip the colon */
-
-	++value;
-
-	/* strip the value */
-
-	value = StripLeft(value, end);
-	end = StripRight(value, end);
-
-	headers.emplace(std::move(name), std::string(value, end));
-}
-
-std::size_t
-CurlRequest::_HeaderFunction(char *ptr, std::size_t size, std::size_t nmemb,
-			     void *stream) noexcept
-{
-	CurlRequest &c = *(CurlRequest *)stream;
-
-	size *= nmemb;
-
-	c.HeaderFunction({ptr, size});
-	return size;
-}
-
-inline std::size_t
-CurlRequest::DataReceived(const void *ptr, std::size_t received_size) noexcept
-{
-	assert(received_size > 0);
-
-	try {
-		FinishHeaders();
-		handler.OnData({ptr, received_size});
-		return received_size;
-	} catch (CurlResponseHandler::Pause) {
-		return CURL_WRITEFUNC_PAUSE;
-	}
-
-}
-
-std::size_t
-CurlRequest::WriteFunction(char *ptr, std::size_t size, std::size_t nmemb,
-			   void *stream) noexcept
-{
-	CurlRequest &c = *(CurlRequest *)stream;
-
-	size *= nmemb;
-	if (size == 0)
-		return 0;
-
-	return c.DataReceived(ptr, size);
+	handler.Done(result);
 }
diff --git a/src/lib/curl/Request.hxx b/src/lib/curl/Request.hxx
index fae401a4e..fcd68ca8e 100644
--- a/src/lib/curl/Request.hxx
+++ b/src/lib/curl/Request.hxx
@@ -31,9 +31,9 @@
 #define CURL_REQUEST_HXX
 
 #include "Easy.hxx"
+#include "Adapter.hxx"
 
-#include <map>
-#include <string>
+#include <cstddef>
 
 struct StringView;
 class CurlGlobal;
@@ -48,22 +48,11 @@ class CurlResponseHandler;
 class CurlRequest final {
 	CurlGlobal &global;
 
-	CurlResponseHandler &handler;
+	CurlResponseHandlerAdapter handler;
 
 	/** the curl handle */
 	CurlEasy easy;
 
-	enum class State {
-		HEADERS,
-		BODY,
-		CLOSED,
-	} state = State::HEADERS;
-
-	std::multimap<std::string, std::string> headers;
-
-	/** error message provided by libcurl */
-	char error_buffer[CURL_ERROR_SIZE];
-
 	bool registered = false;
 
 public:
@@ -164,18 +153,6 @@ private:
 
 	void FinishHeaders();
 	void FinishBody();
-
-	std::size_t DataReceived(const void *ptr, std::size_t size) noexcept;
-
-	void HeaderFunction(StringView s) noexcept;
-
-	/** called by curl when new data is available */
-	static std::size_t _HeaderFunction(char *ptr, std::size_t size, std::size_t nmemb,
-					   void *stream) noexcept;
-
-	/** called by curl when new data is available */
-	static std::size_t WriteFunction(char *ptr, std::size_t size, std::size_t nmemb,
-					 void *stream) noexcept;
 };
 
 #endif
diff --git a/src/lib/curl/meson.build b/src/lib/curl/meson.build
index 3433997e0..284c688d8 100644
--- a/src/lib/curl/meson.build
+++ b/src/lib/curl/meson.build
@@ -18,6 +18,7 @@ curl = static_library(
   'Init.cxx',
   'Global.cxx',
   'Request.cxx',
+  'Adapter.cxx',
   'Escape.cxx',
   'Form.cxx',
   include_directories: inc,