diff --git a/NEWS b/NEWS
index 0bff87f25..2ee609f6f 100644
--- a/NEWS
+++ b/NEWS
@@ -17,18 +17,23 @@ ver 0.23 (not yet released)
   - new tags "ComposerSort", "Ensemble", "Movement", "MovementNumber", and "Location"
 * new build-time dependency: libfmt
 
-ver 0.22.10 (not yet released)
+ver 0.22.10 (2021/08/06)
 * protocol
   - support "albumart" for virtual tracks in CUE sheets
 * database
   - simple: fix crash bug
+  - simple: fix absolute paths in CUE "as_directory" entries
+  - simple: prune CUE entries from database for non-existent songs
 * input
   - curl: fix crash bug after stream with Icy metadata was closed by peer
   - tidal: remove defunct unmaintained plugin
 * tags
   - fix crash caused by bug in TagBuilder and a few potential reference leaks
 * output
+  - httpd: fix missing tag after seeking into a new song
   - oss: fix channel order of multi-channel files
+* mixer
+  - alsa: fix yet more rounding errors
 
 ver 0.22.9 (2021/06/23)
 * database
diff --git a/android/build.py b/android/build.py
index eb6600111..664bcccdd 100755
--- a/android/build.py
+++ b/android/build.py
@@ -91,8 +91,9 @@ class AndroidNdkToolchain:
 
         self.arch = arch
         self.install_prefix = install_prefix
+        self.toolchain_arch = abi_info['toolchain_arch']
 
-        toolchain_path = os.path.join(ndk_path, 'toolchains', abi_info['toolchain_arch'] + '-' + gcc_version, 'prebuilt', build_arch)
+        toolchain_path = os.path.join(ndk_path, 'toolchains', self.toolchain_arch + '-' + gcc_version, 'prebuilt', build_arch)
         llvm_path = os.path.join(ndk_path, 'toolchains', 'llvm', 'prebuilt', build_arch)
         llvm_triple = abi_info['llvm_triple'] + android_api_level
 
diff --git a/doc/plugins.rst b/doc/plugins.rst
index 8e9c4582f..8573ae8a8 100644
--- a/doc/plugins.rst
+++ b/doc/plugins.rst
@@ -1257,7 +1257,7 @@ This plugin requires building with ``libavfilter`` (FFmpeg).
    * - **graph "..."**
      - Specifies the ``libavfilter`` graph; read the `FFmpeg
        documentation
-       <https://libav.org/documentation/libavfilter.html#Filtergraph-syntax-1>`_
+       <https://ffmpeg.org/ffmpeg-filters.html#Filtergraph-syntax-1>`_
        for details
 
 
diff --git a/python/build/libs.py b/python/build/libs.py
index 419a52a66..fcc349193 100644
--- a/python/build/libs.py
+++ b/python/build/libs.py
@@ -388,14 +388,14 @@ ffmpeg = FfmpegProject(
 )
 
 openssl = OpenSSLProject(
-    'https://www.openssl.org/source/openssl-3.0.0-alpha16.tar.gz',
-    '08ce8244b59d75f40f91170dfcb012bf25309cdcb1fef9502e39d694f883d1d1',
+    'https://www.openssl.org/source/openssl-3.0.0-beta2.tar.gz',
+    'e76ab22879201b12f014393ee4becec7f264d8f6955b1036839128002868df71',
     'include/openssl/ossl_typ.h',
 )
 
 curl = AutotoolsProject(
-    'https://curl.se/download/curl-7.76.1.tar.xz',
-    '64bb5288c39f0840c07d077e30d9052e1cbb9fa6c2dc52523824cc859e679145',
+    'https://curl.se/download/curl-7.78.0.tar.xz',
+    'be42766d5664a739c3974ee3dfbbcbe978a4ccb1fe628bb1d9b59ac79e445fb5',
     'lib/libcurl.a',
     [
         '--disable-shared', '--enable-static',
diff --git a/python/build/openssl.py b/python/build/openssl.py
index a7350e6ac..605a04c74 100644
--- a/python/build/openssl.py
+++ b/python/build/openssl.py
@@ -17,6 +17,12 @@ class OpenSSLProject(MakeProject):
             'build_libs',
         ]
 
+    def get_make_install_args(self, toolchain):
+        # OpenSSL's Makefile runs "ranlib" during installation
+        return MakeProject.get_make_install_args(self, toolchain) + [
+            'RANLIB=' + toolchain.ranlib,
+        ]
+
     def build(self, toolchain):
         src = self.unpack(toolchain, out_of_tree=False)
 
@@ -42,6 +48,7 @@ class OpenSSLProject(MakeProject):
         }
 
         openssl_arch = openssl_archs[toolchain.arch]
+        cross_compile_prefix = toolchain.toolchain_arch + '-'
 
         subprocess.check_call(['./Configure',
                                'no-shared',
@@ -50,6 +57,7 @@ class OpenSSLProject(MakeProject):
                                'no-tests',
                                'no-asm', # "asm" causes build failures on Windows
                                openssl_arch,
+                               '--cross-compile-prefix=' + cross_compile_prefix,
                                '--prefix=' + toolchain.install_prefix],
                               cwd=src, env=toolchain.env)
         MakeProject.build(self, toolchain, src)
diff --git a/python/build/project.py b/python/build/project.py
index 374ccdb14..e0868b27b 100644
--- a/python/build/project.py
+++ b/python/build/project.py
@@ -20,7 +20,7 @@ class Project:
             self.base = base
 
         if name is None or version is None:
-            m = re.match(r'^([-\w]+)-(\d[\d.]*[a-z]?[\d.]*(?:-alpha\d+)?)(\+.*)?$', self.base)
+            m = re.match(r'^([-\w]+)-(\d[\d.]*[a-z]?[\d.]*(?:-(?:alpha|beta)\d+)?)$', self.base)
             if name is None: name = m.group(1)
             if version is None: version = m.group(2)
 
diff --git a/src/db/plugins/simple/Directory.cxx b/src/db/plugins/simple/Directory.cxx
index 4c7bb2873..b03994730 100644
--- a/src/db/plugins/simple/Directory.cxx
+++ b/src/db/plugins/simple/Directory.cxx
@@ -109,6 +109,23 @@ Directory::FindChild(std::string_view name) const noexcept
 	return nullptr;
 }
 
+bool
+Directory::TargetExists(std::string_view _target) const noexcept
+{
+	StringView target{_target};
+
+	if (target.SkipPrefix("../")) {
+		if (parent == nullptr)
+			return false;
+
+		return parent->TargetExists(target);
+	}
+
+	/* sorry for the const_cast ... */
+	const auto lr = const_cast<Directory *>(this)->LookupDirectory(target);
+	return lr.directory->FindSong(lr.rest) != nullptr;
+}
+
 void
 Directory::PruneEmpty() noexcept
 {
diff --git a/src/db/plugins/simple/Directory.hxx b/src/db/plugins/simple/Directory.hxx
index 670860de6..87e8351e7 100644
--- a/src/db/plugins/simple/Directory.hxx
+++ b/src/db/plugins/simple/Directory.hxx
@@ -118,13 +118,17 @@ public:
 		return new Directory(std::string(), nullptr);
 	}
 
+	bool IsPlaylist() const noexcept {
+		return device == DEVICE_PLAYLIST;
+	}
+
 	/**
 	 * Is this really a regular file which is being treated like a
 	 * directory?
 	 */
 	bool IsReallyAFile() const noexcept {
 		return device == DEVICE_INARCHIVE ||
-			device == DEVICE_PLAYLIST ||
+			IsPlaylist() ||
 			device == DEVICE_CONTAINER;
 	}
 
@@ -206,11 +210,13 @@ public:
 	 * Looks up a directory by its relative URI.
 	 *
 	 * @param uri the relative URI
-	 * @return the Directory, or nullptr if none was found
 	 */
 	gcc_pure
 	LookupResult LookupDirectory(std::string_view uri) noexcept;
 
+	[[gnu::pure]]
+	bool TargetExists(std::string_view target) const noexcept;
+
 	gcc_pure
 	bool IsEmpty() const noexcept {
 		return children.empty() &&
diff --git a/src/db/update/Playlist.cxx b/src/db/update/Playlist.cxx
index 855b055f1..db4eddb73 100644
--- a/src/db/update/Playlist.cxx
+++ b/src/db/update/Playlist.cxx
@@ -31,6 +31,7 @@
 #include "playlist/SongEnumerator.hxx"
 #include "storage/FileInfo.hxx"
 #include "storage/StorageInterface.hxx"
+#include "fs/Traits.hxx"
 #include "util/StringFormat.hxx"
 #include "Log.hxx"
 
@@ -71,7 +72,14 @@ UpdateWalk::UpdatePlaylistFile(Directory &parent, std::string_view name,
 
 			auto db_song = std::make_unique<Song>(std::move(*song),
 							      *directory);
-			db_song->target = "../" + db_song->filename;
+			db_song->target =
+				PathTraitsUTF8::IsAbsoluteOrHasScheme(db_song->filename.c_str())
+				? db_song->filename
+				/* prepend "../" to relative paths to
+				   go from the virtual directory
+				   (DEVICE_PLAYLIST) to the containing
+				   directory */
+				: "../" + db_song->filename;
 			db_song->filename = StringFormat<64>("track%04u",
 							     ++track);
 
diff --git a/src/db/update/Walk.cxx b/src/db/update/Walk.cxx
index 74583b90d..168051d56 100644
--- a/src/db/update/Walk.cxx
+++ b/src/db/update/Walk.cxx
@@ -133,6 +133,28 @@ UpdateWalk::PurgeDeletedFromDirectory(Directory &directory) noexcept
 	}
 }
 
+void
+UpdateWalk::PurgeDanglingFromPlaylists(Directory &directory) noexcept
+{
+	/* recurse */
+	for (Directory &child : directory.children)
+		PurgeDanglingFromPlaylists(child);
+
+	if (!directory.IsPlaylist())
+		/* this check is only for virtual directories
+		   representing a playlist file */
+		return;
+
+	directory.ForEachSongSafe([&](Song &song){
+		if (!song.target.empty() &&
+		    !PathTraitsUTF8::IsAbsoluteOrHasScheme(song.target.c_str()) &&
+		    !directory.TargetExists(song.target)) {
+			editor.DeleteSong(directory, &song);
+			modified = true;
+		}
+	});
+}
+
 #ifndef _WIN32
 static bool
 update_directory_stat(Storage &storage, Directory &directory) noexcept
@@ -530,5 +552,10 @@ UpdateWalk::Walk(Directory &root, const char *path, bool discard) noexcept
 		UpdateDirectory(root, exclude_list, info);
 	}
 
+	{
+		const ScopeDatabaseLock protect;
+		PurgeDanglingFromPlaylists(root);
+	}
+
 	return modified;
 }
diff --git a/src/db/update/Walk.hxx b/src/db/update/Walk.hxx
index e1ac9b8ec..b2417fab4 100644
--- a/src/db/update/Walk.hxx
+++ b/src/db/update/Walk.hxx
@@ -85,6 +85,12 @@ private:
 
 	void PurgeDeletedFromDirectory(Directory &directory) noexcept;
 
+	/**
+	 * Remove all virtual songs inside playlists whose "target"
+	 * field points to a non-existing song file.
+	 */
+	void PurgeDanglingFromPlaylists(Directory &directory) noexcept;
+
 	void UpdateSongFile2(Directory &directory,
 			     const char *name, std::string_view suffix,
 			     const StorageFileInfo &info) noexcept;
diff --git a/src/decoder/Bridge.cxx b/src/decoder/Bridge.cxx
index 5f326a7f1..81b8b8f8e 100644
--- a/src/decoder/Bridge.cxx
+++ b/src/decoder/Bridge.cxx
@@ -582,10 +582,6 @@ DecoderBridge::SubmitTag(InputStream *is, Tag &&tag) noexcept
 
 	decoder_tag = std::make_unique<Tag>(std::move(tag));
 
-	/* check for a new stream tag */
-
-	UpdateStreamTag(is);
-
 	/* check if we're seeking */
 
 	if (PrepareInitialSeek())
@@ -594,6 +590,10 @@ DecoderBridge::SubmitTag(InputStream *is, Tag &&tag) noexcept
 		   function here */
 		return DecoderCommand::SEEK;
 
+	/* check for a new stream tag */
+
+	UpdateStreamTag(is);
+
 	/* send tag to music pipe */
 
 	if (stream_tag != nullptr)
diff --git a/src/fs/Traits.cxx b/src/fs/Traits.cxx
index 35d7fa1fd..32f65105a 100644
--- a/src/fs/Traits.cxx
+++ b/src/fs/Traits.cxx
@@ -19,6 +19,7 @@
 
 #include "Traits.hxx"
 #include "util/StringCompare.hxx"
+#include "util/UriExtract.hxx"
 
 #include <string.h>
 
@@ -220,6 +221,12 @@ PathTraitsUTF8::Build(string_view a, string_view b) noexcept
 	return BuildPathImpl<PathTraitsUTF8>(a, b);
 }
 
+bool
+PathTraitsUTF8::IsAbsoluteOrHasScheme(const_pointer p) noexcept
+{
+	return IsAbsolute(p) || uri_has_scheme(p);
+}
+
 PathTraitsUTF8::const_pointer
 PathTraitsUTF8::GetBase(const_pointer p) noexcept
 {
diff --git a/src/fs/Traits.hxx b/src/fs/Traits.hxx
index a5495a29f..f88cf33ac 100644
--- a/src/fs/Traits.hxx
+++ b/src/fs/Traits.hxx
@@ -274,6 +274,13 @@ struct PathTraitsUTF8 {
 		return IsSeparator(*p);
 	}
 
+	/**
+	 * Is this any kind of absolute URI? (Unlike IsAbsolute(),
+	 * this includes URIs/URLs with a scheme)
+	 */
+	[[gnu::pure]] [[gnu::nonnull]]
+	static bool IsAbsoluteOrHasScheme(const_pointer p) noexcept;
+
 	gcc_pure gcc_nonnull_all
 	static bool IsSpecialFilename(const_pointer name) noexcept {
 		return (name[0] == '.' && name[1] == 0) ||
diff --git a/src/mixer/plugins/AlsaMixerPlugin.cxx b/src/mixer/plugins/AlsaMixerPlugin.cxx
index 30e158be2..5649f765e 100644
--- a/src/mixer/plugins/AlsaMixerPlugin.cxx
+++ b/src/mixer/plugins/AlsaMixerPlugin.cxx
@@ -83,6 +83,24 @@ class AlsaMixer final : public Mixer {
 
 	AlsaMixerMonitor *monitor;
 
+	/**
+	 * These fields are our workaround for rounding errors when
+	 * the resolution of a mixer knob isn't fine enough to
+	 * represent all 101 possible values (0..100).
+	 *
+	 * "desired_volume" is the percent value passed to
+	 * SetVolume(), and "resulting_volume" is the volume which was
+	 * actually set, and would be returned by the next
+	 * GetPercentVolume() call.
+	 *
+	 * When GetVolume() is called, we compare the
+	 * "resulting_volume" with the value returned by
+	 * GetPercentVolume(), and if it's the same, we're still on
+	 * the same value that was previously set (but may have been
+	 * rounded down or up).
+	 */
+	int desired_volume, resulting_volume;
+
 public:
 	AlsaMixer(EventLoop &_event_loop, MixerListener &_listener)
 		:Mixer(alsa_mixer_plugin, _listener),
@@ -101,6 +119,27 @@ public:
 	void Close() noexcept override;
 	int GetVolume() override;
 	void SetVolume(unsigned volume) override;
+
+private:
+	[[gnu::const]]
+	static unsigned NormalizedToPercent(double normalized) noexcept {
+		return lround(100 * normalized);
+	}
+
+	[[gnu::pure]]
+	double GetNormalizedVolume() const noexcept {
+		return get_normalized_playback_volume(elem,
+						      SND_MIXER_SCHN_FRONT_LEFT);
+	}
+
+	[[gnu::pure]]
+	unsigned GetPercentVolume() const noexcept {
+		return NormalizedToPercent(GetNormalizedVolume());
+	}
+
+	static int ElemCallback(snd_mixer_elem_t *elem,
+				unsigned mask) noexcept;
+
 };
 
 static constexpr Domain alsa_mixer_domain("alsa_mixer");
@@ -144,18 +183,26 @@ AlsaMixerMonitor::DispatchSockets() noexcept
  *
  */
 
-static int
-alsa_mixer_elem_callback(snd_mixer_elem_t *elem, unsigned mask)
+int
+AlsaMixer::ElemCallback(snd_mixer_elem_t *elem, unsigned mask) noexcept
 {
 	AlsaMixer &mixer = *(AlsaMixer *)
 		snd_mixer_elem_get_callback_private(elem);
 
 	if (mask & SND_CTL_EVENT_MASK_VALUE) {
-		try {
-			int volume = mixer.GetVolume();
-			mixer.listener.OnMixerVolumeChanged(mixer, volume);
-		} catch (...) {
-		}
+		int volume = mixer.GetPercentVolume();
+
+		if (mixer.resulting_volume >= 0 &&
+		    volume == mixer.resulting_volume)
+			/* still the same volume (this might be a
+			   callback caused by SetVolume()) - switch to
+			   desired_volume */
+			volume = mixer.desired_volume;
+		else
+			/* flush */
+			mixer.desired_volume = mixer.resulting_volume = -1;
+
+		mixer.listener.OnMixerVolumeChanged(mixer, volume);
 	}
 
 	return 0;
@@ -233,7 +280,7 @@ AlsaMixer::Setup()
 		throw FormatRuntimeError("no such mixer control: %s", control);
 
 	snd_mixer_elem_set_callback_private(elem, this);
-	snd_mixer_elem_set_callback(elem, alsa_mixer_elem_callback);
+	snd_mixer_elem_set_callback(elem, ElemCallback);
 
 	monitor = new AlsaMixerMonitor(event_loop, handle);
 }
@@ -241,6 +288,8 @@ AlsaMixer::Setup()
 void
 AlsaMixer::Open()
 {
+	desired_volume = resulting_volume = -1;
+
 	int err;
 
 	err = snd_mixer_open(&handle, 0);
@@ -279,7 +328,12 @@ AlsaMixer::GetVolume()
 		throw FormatRuntimeError("snd_mixer_handle_events() failed: %s",
 					 snd_strerror(err));
 
-	return lround(100 * get_normalized_playback_volume(elem, SND_MIXER_SCHN_FRONT_LEFT));
+	int volume = GetPercentVolume();
+	if (resulting_volume >= 0 && volume == resulting_volume)
+		/* we're still on the value passed to SetVolume() */
+		volume = desired_volume;
+
+	return volume;
 }
 
 void
@@ -287,12 +341,13 @@ AlsaMixer::SetVolume(unsigned volume)
 {
 	assert(handle != nullptr);
 
-	double cur = get_normalized_playback_volume(elem, SND_MIXER_SCHN_FRONT_LEFT);
-	int delta = volume - lround(100.*cur);
-	int err = set_normalized_playback_volume(elem, cur + 0.01*delta, delta);
+	int err = set_normalized_playback_volume(elem, 0.01*volume, 1);
 	if (err < 0)
 		throw FormatRuntimeError("failed to set ALSA volume: %s",
 					 snd_strerror(err));
+
+	desired_volume = volume;
+	resulting_volume = GetPercentVolume();
 }
 
 const MixerPlugin alsa_mixer_plugin = {
diff --git a/src/playlist/PlaylistSong.cxx b/src/playlist/PlaylistSong.cxx
index 3cecbaeab..349282456 100644
--- a/src/playlist/PlaylistSong.cxx
+++ b/src/playlist/PlaylistSong.cxx
@@ -95,8 +95,8 @@ playlist_check_translate_song(DetachedSong &song, std::string_view base_uri,
 	}
 #endif
 
-	if (base_uri.data() != nullptr && !uri_has_scheme(uri) &&
-	    !PathTraitsUTF8::IsAbsolute(uri))
+	if (base_uri.data() != nullptr &&
+	    !PathTraitsUTF8::IsAbsoluteOrHasScheme(uri))
 		song.SetURI(PathTraitsUTF8::Build(base_uri, uri));
 
 	return playlist_check_load_song(song, loader);
diff --git a/src/song/DetachedSong.cxx b/src/song/DetachedSong.cxx
index e79595d75..0dc151129 100644
--- a/src/song/DetachedSong.cxx
+++ b/src/song/DetachedSong.cxx
@@ -61,7 +61,7 @@ DetachedSong::IsInDatabase() const noexcept
 	   GetRealURI() is never relative */
 
 	const char *_uri = GetURI();
-	return !uri_has_scheme(_uri) && !PathTraitsUTF8::IsAbsolute(_uri);
+	return !PathTraitsUTF8::IsAbsoluteOrHasScheme(_uri);
 }
 
 SignedSongTime