[vlc-devel] [PATCH 14/14] upnp_server: add the upnp server module

Alaric Senat dev.asenat at posteo.net
Fri Mar 19 11:40:09 UTC 2021


This is the first implementation of a vlc upnp server module.
The module behave as an interface, it works in pair with the
medialibrary API to expose most of its content.

Here is a list of the server main features:
  - Very straightforward to deploy, you start vlc with `-I upnp` and it
    simply exposes the medialibrary on the local network.
  - The server automatically exposes downscaled versions of your videos
  - While the DLNA spec is far from being fully supported, a lot of DLNA
    clients are supported.
  - DLNA's "time based seeking" during transcoding is supported.
  - Special transcoding profiles depending on client's user-agent are
    suported, for the moment only the PLAYSTATION3 has differents
    profiles hardcoded in the source code but it should be extensible to
    other specific clients eventually using config files.

The module is split into different parts, here's a quick overview:
  - upnp_server.cpp: The core of the module, all the interactions with
    libupnp are done here, this file brings all the module parts
    together.
  - cds/*: The "ContentDirectory Service", represents and implements the
    server file hierarchy, in our case, it mostly reflect the
    medialibrary content.
  - sout.cpp: The access out upnp server submodule internals. Used in
    media transcoding to make the connection between the vlc transcode
    pipeline output and the upnp HTTP callbacks.
  - xml_wrapper: Wrap the xmli library with modern c++ code to fit the
    codebase better.
  - test/*: Unit tests

The upnp_server module is based on Hamza Parnica's great proof of
concept.
---
 modules/control/upnp_server/.clang-format     |  16 +
 modules/control/upnp_server/Clients.cpp       | 126 ++++
 modules/control/upnp_server/FileHandler.cpp   | 501 ++++++++++++++++
 modules/control/upnp_server/FileHandler.hpp   |  54 ++
 modules/control/upnp_server/Option.hpp        | 126 ++++
 modules/control/upnp_server/Profiles.hpp      |  57 ++
 modules/control/upnp_server/cds/Container.hpp |  77 +++
 .../upnp_server/cds/FixedContainer.cpp        |  72 +++
 .../upnp_server/cds/FixedContainer.hpp        |  55 ++
 modules/control/upnp_server/cds/Item.cpp      | 246 ++++++++
 modules/control/upnp_server/cds/Item.hpp      |  55 ++
 .../control/upnp_server/cds/MLContainer.hpp   | 202 +++++++
 .../upnp_server/cds/MLFolderContainer.hpp     | 152 +++++
 modules/control/upnp_server/cds/Object.hpp    | 123 ++++
 modules/control/upnp_server/cds/cds.cpp       | 153 +++++
 modules/control/upnp_server/cds/cds.hpp       |  56 ++
 modules/control/upnp_server/ml.hpp            | 144 +++++
 .../upnp_server/share/ConnectionManager.xml   | 132 +++++
 .../upnp_server/share/ContentDirectory.xml    | 207 +++++++
 .../share/X_MS_MediaReceiverRegistrar.xml     |  88 +++
 modules/control/upnp_server/sout.cpp          | 220 +++++++
 modules/control/upnp_server/test/cds.cpp      | 113 ++++
 .../upnp_server/test/cxx_test_helper.hpp      | 257 +++++++++
 modules/control/upnp_server/test/main.cpp     |  26 +
 modules/control/upnp_server/test/utils.cpp    |  71 +++
 modules/control/upnp_server/upnp_server.cpp   | 546 ++++++++++++++++++
 modules/control/upnp_server/upnp_server.hpp   | 133 +++++
 modules/control/upnp_server/utils.cpp         | 280 +++++++++
 modules/control/upnp_server/utils.hpp         |  93 +++
 modules/control/upnp_server/xml_wrapper.hpp   | 130 +++++
 modules/services_discovery/Makefile.am        |  41 +-
 modules/services_discovery/upnp.cpp           |  20 +
 modules/services_discovery/upnp.hpp           |   1 +
 33 files changed, 4572 insertions(+), 1 deletion(-)
 create mode 100644 modules/control/upnp_server/.clang-format
 create mode 100644 modules/control/upnp_server/Clients.cpp
 create mode 100644 modules/control/upnp_server/FileHandler.cpp
 create mode 100644 modules/control/upnp_server/FileHandler.hpp
 create mode 100644 modules/control/upnp_server/Option.hpp
 create mode 100644 modules/control/upnp_server/Profiles.hpp
 create mode 100644 modules/control/upnp_server/cds/Container.hpp
 create mode 100644 modules/control/upnp_server/cds/FixedContainer.cpp
 create mode 100644 modules/control/upnp_server/cds/FixedContainer.hpp
 create mode 100644 modules/control/upnp_server/cds/Item.cpp
 create mode 100644 modules/control/upnp_server/cds/Item.hpp
 create mode 100644 modules/control/upnp_server/cds/MLContainer.hpp
 create mode 100644 modules/control/upnp_server/cds/MLFolderContainer.hpp
 create mode 100644 modules/control/upnp_server/cds/Object.hpp
 create mode 100644 modules/control/upnp_server/cds/cds.cpp
 create mode 100644 modules/control/upnp_server/cds/cds.hpp
 create mode 100644 modules/control/upnp_server/ml.hpp
 create mode 100644 modules/control/upnp_server/share/ConnectionManager.xml
 create mode 100644 modules/control/upnp_server/share/ContentDirectory.xml
 create mode 100644 modules/control/upnp_server/share/X_MS_MediaReceiverRegistrar.xml
 create mode 100644 modules/control/upnp_server/sout.cpp
 create mode 100644 modules/control/upnp_server/test/cds.cpp
 create mode 100644 modules/control/upnp_server/test/cxx_test_helper.hpp
 create mode 100644 modules/control/upnp_server/test/main.cpp
 create mode 100644 modules/control/upnp_server/test/utils.cpp
 create mode 100644 modules/control/upnp_server/upnp_server.cpp
 create mode 100644 modules/control/upnp_server/upnp_server.hpp
 create mode 100644 modules/control/upnp_server/utils.cpp
 create mode 100644 modules/control/upnp_server/utils.hpp
 create mode 100644 modules/control/upnp_server/xml_wrapper.hpp

diff --git a/modules/control/upnp_server/.clang-format b/modules/control/upnp_server/.clang-format
new file mode 100644
index 0000000000..b6e5517f52
--- /dev/null
+++ b/modules/control/upnp_server/.clang-format
@@ -0,0 +1,16 @@
+---
+AccessModifierOffset: '0'
+AllowAllConstructorInitializersOnNextLine: 'false'
+AlwaysBreakTemplateDeclarations: 'Yes'
+BinPackParameters: 'false'
+BreakBeforeBraces: Allman
+BreakConstructorInitializers: BeforeComma
+ColumnLimit: '100'
+ConstructorInitializerAllOnOneLineOrOnePerLine: 'true'
+ConstructorInitializerIndentWidth: '4'
+IndentWidth: '4'
+PointerAlignment: Left
+SpaceBeforeParens: ControlStatements
+SpacesInParentheses: 'true'
+
+...
diff --git a/modules/control/upnp_server/Clients.cpp b/modules/control/upnp_server/Clients.cpp
new file mode 100644
index 0000000000..008d0fd8a9
--- /dev/null
+++ b/modules/control/upnp_server/Clients.cpp
@@ -0,0 +1,126 @@
+/*****************************************************************************
+ * Clients.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "upnp_server.hpp"
+
+#include <vlc_common.h>
+#include <vlc_codec.h>
+
+#include "utils.hpp"
+
+#include "../stream_out/renderer_common.hpp"
+
+#include <sstream>
+#include <unordered_map>
+
+const std::unordered_map<std::string, ClientProfile> CLIENT_PROFILES = {
+
+  { "PLAYSTATION 3",
+    {
+      {
+        {
+          "video_ps3",
+          CONVERSION_QUALITY_LOW,
+          VLC_ML_MEDIA_TYPE_VIDEO,
+          {VLC_CODEC_H264},
+          VLC_CODEC_A52,
+          {"ts", "ts", "AVC_TS_EU_ISO"}
+        },
+        {
+          "audio_ps3",
+          CONVERSION_QUALITY_HIGH,
+          VLC_ML_MEDIA_TYPE_AUDIO,
+          {},
+          VLC_CODEC_S16B,
+          {"raw", "L16;rate=48000;channels=2", "L16"}
+        }
+      },
+      false // native_resource_first
+    }
+  }
+};
+
+const ClientProfile DEFAULT_PROFILE = {
+  {
+    {
+      "av_high",
+      CONVERSION_QUALITY_HIGH,
+      VLC_ML_MEDIA_TYPE_VIDEO,
+      {VLC_CODEC_H264, VLC_CODEC_VP8},
+      VLC_CODEC_MP4A,
+      {"ts", "ts", "AVC_TS_EU_ISO"}
+    },
+    {
+      "a_high",
+      CONVERSION_QUALITY_HIGH,
+      VLC_ML_MEDIA_TYPE_AUDIO,
+      {},
+      VLC_CODEC_VORBIS,
+      {"ogg", "ogg", "x-vorbis"}
+    },
+    {
+      "av_low",
+      CONVERSION_QUALITY_LOW,
+      VLC_ML_MEDIA_TYPE_VIDEO,
+      {VLC_CODEC_H264, VLC_CODEC_VP8},
+      VLC_CODEC_MP4A,
+      {"ts", "ts", "AVC_TS_EU_ISO"}
+    },
+    {
+      "a_low",
+      CONVERSION_QUALITY_LOW,
+      VLC_ML_MEDIA_TYPE_AUDIO,
+      {},
+      VLC_CODEC_MP3,
+      {"ts", "ts", "MP3X"}
+    }
+  }
+};
+
+const ClientProfile& intf_sys_t::default_profile() noexcept { return DEFAULT_PROFILE; }
+
+const ClientProfile& intf_sys_t::profile_from_headers( UpnpListHead* headers,
+                                                       const char* user_agent ) noexcept
+{
+    const char* av_client_info = utils::http::get_extra_header( headers, "x-av-client-info" );
+
+    std::string key;
+
+    // Extract the "mn" field from the header value
+    if ( av_client_info )
+    {
+        key = av_client_info;
+        const auto name_idx = key.find("mn=\"");
+        if (name_idx != std::string::npos) {
+          const char * begin = key.c_str() + name_idx + 4;
+          const char * end= strchr(begin, '\"');
+          if (end != nullptr) {
+            key = key.substr(name_idx + 4, end - begin);
+          }
+        }
+    }
+    else if ( user_agent )
+        key = user_agent;
+
+    if ( CLIENT_PROFILES.find( key ) != std::end( CLIENT_PROFILES ) )
+        return CLIENT_PROFILES.at( key );
+    return DEFAULT_PROFILE;
+}
diff --git a/modules/control/upnp_server/FileHandler.cpp b/modules/control/upnp_server/FileHandler.cpp
new file mode 100644
index 0000000000..05bdc5ba70
--- /dev/null
+++ b/modules/control/upnp_server/FileHandler.cpp
@@ -0,0 +1,501 @@
+/*****************************************************************************
+ * FileHandler.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "FileHandler.hpp"
+
+#include "utils.hpp"
+
+#include <vlc_common.h>
+
+#include <vlc_addons.h>
+#include <vlc_cxx_helpers.hpp>
+#include <vlc_interface.h>
+#include <vlc_player.h>
+#include <vlc_rand.h>
+#include <vlc_stream.h>
+#include <vlc_stream_extractor.h>
+#include <vlc_url.h>
+#include <vlc_fourcc.h>
+
+#include <ctime>
+#include <sstream>
+
+#include "stream_out/renderer_common.hpp"
+
+static constexpr char DLNA_TRANSFER_MODE[] = "transfermode.dlna.org";
+static constexpr char DLNA_CONTENT_FEATURE[] = "contentfeatures.dlna.org";
+static constexpr char DLNA_TIME_SEEK_RANGE[] = "timeseekrange.dlna.org";
+
+/// Convenient C++ replacement of vlc_ml_file_t to not have to deal with allocations
+struct MLFile
+{
+    std::string mrl;
+    int64_t size;
+    time_t last_modification;
+};
+
+/// Usual filesystem FileHandler implementation, this is the most commonly used FileHandler, for
+/// non-transcoded local medias, thumbnails and subs.
+struct MLFileHandler : FileHandler
+{
+    MLFile file;
+    utils::MimeType mime_type;
+
+    std::unique_ptr<stream_t, decltype( &vlc_stream_Delete )> stream = { nullptr,
+                                                                         &vlc_stream_Delete };
+
+    MLFileHandler( MLFile&& file, utils::MimeType&& mime_type )
+        : file( std::move( file ) )
+        , mime_type( std::move( mime_type ) )
+    {
+    }
+
+    bool get_info( UpnpFileInfo& info ) noexcept final
+    {
+        UpnpFileInfo_set_ContentType( &info, mime_type.combine().c_str() );
+        UpnpFileInfo_set_FileLength( &info, file.size );
+        UpnpFileInfo_set_LastModified( &info, file.last_modification );
+
+        // const_cast is expected by the design of the upnp api as it only serves const list heads
+        // FIXME: see if there's no way to patch that in libupnp, we shouldn't have to break const
+        // to do something so usual.
+        auto* head = const_cast<UpnpListHead*>( UpnpFileInfo_get_ExtraHeadersList( &info ) );
+        utils::http::add_response_hdr( head, { DLNA_CONTENT_FEATURE, "DLNA.ORG_OP=01" } );
+        return true;
+    }
+
+    bool open( intf_thread_t* parent ) noexcept final
+    {
+        stream = vlc::wrap_cptr( vlc_stream_NewMRL( VLC_OBJECT( parent ), file.mrl.c_str() ),
+                                 &vlc_stream_Delete );
+        return stream != nullptr;
+    }
+
+    size_t read( uint8_t buffer[], size_t buffer_len ) noexcept final
+    {
+        return vlc_stream_Read( stream.get(), buffer, buffer_len );
+    }
+
+    bool seek( SeekType type, off_t offset ) noexcept final
+    {
+        uint64_t real_offset;
+        switch ( type )
+        {
+        case SeekType::Current:
+            real_offset = vlc_stream_Tell( stream.get() ) + offset;
+            break;
+        case SeekType::End:
+            if ( vlc_stream_GetSize( stream.get(), &real_offset ) != VLC_SUCCESS )
+            {
+                return false;
+            }
+            real_offset += offset;
+            break;
+        case SeekType::Set:
+            real_offset = offset;
+            break;
+        default:
+            return false;
+        }
+
+        return vlc_stream_Seek( stream.get(), real_offset ) == 0;
+    }
+};
+
+struct TrandscodedMediaFileHandler : FileHandler
+{
+    utils::MimeType mime_type;
+    const TranscodeProfile& profile;
+
+    ml::Media::Ptr media;
+    time_t media_begin = 0;
+
+    // vlc_object, is wrapped that way here to be able to use a unique_ptr on it.
+    struct VLCObject
+    {
+        // we don't use a destructor to release the object as this struct is allocated using
+        // vlc_object_create. Calling delete on it would potentially cause a mismatch in
+        // allocation/deletion
+        static void release( VLCObject* obj )
+        {
+            vlc_object_t* vlc_object = reinterpret_cast<vlc_object_t*>( obj );
+            vlc_player_Delete( obj->player );
+            auto* fifo =
+                static_cast<std::shared_ptr<TranscodeFifo>*>( var_GetAddress( vlc_object, TranscodeFifo::VAR_NAME ) );
+            delete fifo;
+            var_SetAddress( vlc_object, TranscodeFifo::VAR_NAME, nullptr );
+            vlc_object_release( vlc_object );
+        }
+        using Ptr = std::unique_ptr<VLCObject, decltype( &release )>;
+
+        vlc_object_t obj;
+        vlc_player_t* player;
+    };
+    VLCObject::Ptr obj;
+    std::shared_ptr<TranscodeFifo> fifo = std::make_shared<TranscodeFifo>();
+
+    TrandscodedMediaFileHandler( utils::MimeType&& mime,
+                                 const TranscodeProfile& profile,
+                                 decltype( media )&& media )
+        : mime_type( std::move( mime ) )
+        , profile( profile )
+        , media( std::move( media ) )
+        , obj( nullptr, &VLCObject::release )
+    {
+    }
+
+    bool get_info( UpnpFileInfo& info ) noexcept final
+    {
+        assert( media != nullptr );
+        const auto main_files = utils::get_media_files( *media, VLC_ML_FILE_TYPE_MAIN );
+        if ( main_files.empty() )
+            return false;
+
+        UpnpFileInfo_set_ContentType( &info, mime_type.combine().c_str() );
+        UpnpFileInfo_set_LastModified( &info, main_files.front().get().i_last_modification_date );
+
+        // We can't predict the media size before transcoding.
+        UpnpFileInfo_set_FileLength( &info, -1 );
+
+        // const_cast is expected by the design of the upnp api as it only serves const list heads
+        // FIXME: see if there's no way to patch that in libupnp, we shouldn't have to break const
+        // to do something so usual.
+        auto* head = const_cast<UpnpListHead*>( UpnpFileInfo_get_ExtraHeadersList( &info ) );
+
+        // This header is used by some DLNA clients to seek, it's the only valid way we have now to
+        // seek while transcoding. We get the starting npt in a HTTP GET request, and the
+        // transcoding pipeline is recreated at the requested time
+        const char* time_seek_range = utils::http::get_extra_header( head, DLNA_TIME_SEEK_RANGE );
+        if ( time_seek_range != nullptr )
+        {
+            const time_t media_begin = utils::parse_dlna_npt_range( time_seek_range );
+            this->media_begin = std::min( media_begin, static_cast<time_t>( media->i_duration ) );
+            utils::http::add_response_hdr(
+                head, { DLNA_TIME_SEEK_RANGE, std::to_string( media_begin / 1000. ) + '-' } );
+        }
+
+        utils::http::add_response_hdr( head, { DLNA_TRANSFER_MODE, "Streaming" } );
+        utils::http::add_response_hdr( head, { DLNA_CONTENT_FEATURE, "DLNA.ORG_OP=10" } );
+        utils::http::add_response_hdr( head, { "Pragma", "no-cache" } );
+        utils::http::add_response_hdr( head, { "Cache-Control", "no-cache" } );
+        return true;
+    }
+
+    bool open( intf_thread_t* parent ) noexcept final
+    {
+        obj = VLCObject::Ptr{
+            static_cast<VLCObject*>( vlc_object_create( parent, sizeof( VLCObject ) ) ),
+            &VLCObject::release };
+
+        obj->player = vlc_player_New( &obj->obj, VLC_PLAYER_LOCK_NORMAL, nullptr, nullptr );
+        if ( obj->player == nullptr )
+        {
+            return false;
+        }
+
+        const auto main_files = utils::get_media_files( *media, VLC_ML_FILE_TYPE_MAIN );
+        if ( main_files.empty() )
+            return false;
+        const vlc_ml_file_t& main_file = main_files.front();
+
+        auto item =
+            vlc::wrap_cptr( vlc_ml_get_input_item_by_mrl( parent->p_sys->p_ml, main_file.psz_mrl ),
+                            &input_item_Release );
+        if ( item == nullptr )
+        {
+            return false;
+        }
+
+        // VLC transcode pipeline building
+        std::stringstream transcode_line;
+        // If a specific starting time is set, use the proxy to avoid sending unhandled random
+        // access discontinuities. See upnp-server.h
+        if ( media_begin != 0 )
+        {
+            transcode_line << "sout=#upnp-sout-proxy:transcode{";
+        }
+        else
+        {
+            transcode_line << "sout=#transcode{";
+        }
+
+        switch(profile.media_type)
+        {
+        case VLC_ML_MEDIA_TYPE_VIDEO:
+            transcode_line << get_video_transcoding_params( VLC_OBJECT( parent ), profile );
+            // Fall-through
+        case VLC_ML_MEDIA_TYPE_AUDIO:
+            transcode_line << get_audio_transcoding_params( VLC_OBJECT( parent ), profile );
+            break;
+        }
+        transcode_line << "}:std{access=upnp-out,mux=" << profile.mux.name << "}";
+
+        const auto transcode_line_str = transcode_line.str();
+        int res = input_item_AddOption( item.get(), transcode_line_str.c_str(),
+                                        VLC_INPUT_OPTION_TRUSTED );
+
+        if ( res != VLC_SUCCESS )
+        {
+            msg_Err( parent, "Can't append transcode chain to the player" );
+            return false;
+        }
+
+        msg_Info( parent, "Transcoding '%s' with profile '%s'", media->psz_title,
+                  profile.name.c_str() );
+
+        var_Create( &obj->obj, TranscodeFifo::VAR_NAME, VLC_VAR_ADDRESS );
+        var_SetAddress( &obj->obj, TranscodeFifo::VAR_NAME, new decltype(fifo)(fifo) );
+
+        vlc_player_Lock( obj->player );
+
+        const bool ret = vlc_player_SetCurrentMedia( obj->player, item.get() ) == VLC_SUCCESS;
+
+        if ( media_begin > 0 )
+            vlc_player_SetTime( obj->player, VLC_TICK_FROM_MS( media_begin ) );
+
+        vlc_player_Start( obj->player );
+
+        vlc_player_Unlock( obj->player );
+        return ret;
+    }
+
+    size_t read( uint8_t buffer[], size_t buffer_len ) noexcept final
+    {
+        return fifo->read( buffer, buffer_len );
+    }
+
+    // Byte based seeking isn't and can't be supported in transcoding.
+    bool seek( SeekType, off_t ) noexcept final { return false; }
+
+  private:
+  std::string get_video_transcoding_params( vlc_object_t* parent,
+                                          const TranscodeProfile& profile ) const
+  {
+      std::string out;
+      const auto video_tracks = utils::get_media_tracks( *media, VLC_ML_TRACK_TYPE_VIDEO );
+
+      assert( !profile.vcodecs_fallbacks.empty() );
+      if ( !video_tracks.empty() )
+      {
+
+          video_format_t fmt;
+          video_format_Init( &fmt, VLC_CODEC_I420 );
+
+          const vlc_ml_media_track_t& vtrack = video_tracks[0];
+          video_format_Setup( &fmt, VLC_CODEC_I420, vtrack.v.i_width, vtrack.v.i_height,
+                              vtrack.v.i_width, vtrack.v.i_height, 1, 1 );
+          try
+          {
+              vlc_fourcc_t codec = 0;
+              out = vlc_sout_renderer_GetVcodecOption( VLC_OBJECT( parent ),
+                                                        profile.vcodecs_fallbacks, &codec, &fmt,
+                                                        profile.conversion_quality );
+          }
+          catch ( const std::domain_error& e )
+          {
+              msg_Err( parent, "No video encoder was fitting the transcode chain: %s", e.what() );
+          }
+          video_format_Clean( &fmt );
+      }
+      else
+          msg_Err( parent, "%s had no video tracks", media->psz_title );
+      return out;
+  }
+
+  std::string get_audio_transcoding_params( vlc_object_t* ,
+                                            const TranscodeProfile& profile ) const
+  {
+      std::stringstream out;
+
+      if (profile.acodec == 0)
+        return "";
+
+      const auto audio_tracks = utils::get_media_tracks( *media, VLC_ML_TRACK_TYPE_AUDIO );
+      if ( !audio_tracks.empty() )
+      {
+          char codec[5] = { 0 };
+
+          vlc_fourcc_to_char( profile.acodec, codec );
+          out << "samplerate=48000,channels=2,acodec=" << codec;
+      }
+      return out.str();
+  }
+};
+
+//
+// Url parsing and FileHandler Factory
+//
+
+template <typename MLHelper>
+auto get_ml_object( const std::string& token, std::string& extension, vlc_medialibrary_t& ml )
+{
+    const auto extension_idx = token.find( '.' );
+    extension = token.substr( extension_idx + 1 );
+
+    int64_t ml_id;
+    try
+    {
+        ml_id = std::stoll( token.substr( 0, extension_idx ) );
+    }
+    catch ( const std::invalid_argument& )
+    {
+        return typename MLHelper::Ptr{ nullptr };
+    }
+    return MLHelper::get( ml, ml_id );
+}
+
+static std::unique_ptr<FileHandler> parse_media_url( std::stringstream& ss,
+                                                     const intf_sys_t& intf_sys,
+                                                     const ClientProfile& client)
+{
+    std::string token;
+    std::getline( ss, token, '/' );
+
+    const TranscodeProfile* profile = nullptr;
+    if ( token != "native" )
+    {
+        for ( const auto& current_profile : client.transcode_profiles )
+        {
+            if ( token == current_profile.name )
+            {
+                profile = &current_profile;
+                break;
+            }
+        }
+        if ( profile == nullptr )
+            return nullptr;
+    }
+
+    std::getline( ss, token );
+
+    std::string extension;
+    auto media = get_ml_object<ml::Media>( token, extension, *intf_sys.p_ml );
+    if ( media == nullptr )
+    {
+        return nullptr;
+    }
+
+    const auto main_files = utils::get_media_files( *media, VLC_ML_FILE_TYPE_MAIN );
+    if ( main_files.empty() )
+    {
+        return nullptr;
+    }
+    const vlc_ml_file_t& main_file = main_files.front();
+
+    if ( profile )
+    {
+        auto mime_type = utils::get_mimetype( media->i_type, profile->mux.file_extension );
+        return std::make_unique<TrandscodedMediaFileHandler>( std::move( mime_type ), *profile,
+                                                              std::move( media ) );
+    }
+
+    auto mime_type = utils::get_mimetype( media->i_type, extension );
+    auto ret = std::make_unique<MLFileHandler>(
+        MLFile{ main_file.psz_mrl, main_file.i_size, main_file.i_last_modification_date },
+        std::move( mime_type ) );
+    return ret;
+}
+
+static std::unique_ptr<FileHandler> parse_thumbnail_url( std::stringstream& ss,
+                                                         const intf_sys_t& intf_sys )
+{
+    std::string token;
+    std::getline( ss, token, '/' );
+    vlc_ml_thumbnail_size_t size;
+    if ( token == "small" )
+        size = VLC_ML_THUMBNAIL_SMALL;
+    else if ( token == "banner" )
+        size = VLC_ML_THUMBNAIL_BANNER;
+    else
+        return nullptr;
+
+    std::getline( ss, token, '/' );
+    std::string extension;
+    std::string mrl;
+    if ( token == "media" )
+    {
+        std::getline( ss, token );
+        const auto media = get_ml_object<ml::Media>( token, extension, *intf_sys.p_ml );
+        if ( media && media->thumbnails[size].i_status == VLC_ML_THUMBNAIL_STATUS_AVAILABLE )
+            mrl = media->thumbnails[size].psz_mrl;
+    }
+    else if ( token == "album" )
+    {
+        std::getline( ss, token );
+        const auto album = get_ml_object<ml::Album>( token, extension, *intf_sys.p_ml );
+        if ( album && album->thumbnails[size].i_status == VLC_ML_THUMBNAIL_STATUS_AVAILABLE )
+            mrl = album->thumbnails[size].psz_mrl;
+    }
+
+    if ( mrl.empty() )
+    {
+        return nullptr;
+    }
+
+    return std::make_unique<MLFileHandler>( MLFile{ mrl, -1, 0 },
+                                            utils::MimeType{ "image", extension } );
+}
+
+static std::unique_ptr<FileHandler> parse_subtitle_url( std::stringstream& ss,
+                                                        const intf_sys_t& intf_sys )
+{
+    std::string token;
+    std::string extension;
+    std::string mrl;
+
+    std::getline( ss, token );
+    const auto media = get_ml_object<ml::Media>( token, extension, *intf_sys.p_ml );
+    if ( media == nullptr )
+    {
+        return nullptr;
+    }
+
+    const auto subtitles = utils::get_media_files( *media, VLC_ML_FILE_TYPE_SUBTITLE );
+    if ( subtitles.empty() )
+    {
+        return nullptr;
+    }
+
+    const vlc_ml_file_t& sub = subtitles.front();
+    return std::make_unique<MLFileHandler>(
+        MLFile{ sub.psz_mrl, sub.i_size, sub.i_last_modification_date },
+        utils::MimeType{ "text", extension } );
+}
+
+std::unique_ptr<FileHandler>
+parse_url( const char* url, const ClientProfile& client, const intf_sys_t& intf_sys ) noexcept
+{
+    std::stringstream ss( url );
+
+    std::string token;
+    std::getline( ss, token, '/' );
+    if ( !token.empty() )
+        return nullptr;
+
+    std::getline( ss, token, '/' );
+    if ( token == "media" )
+        return parse_media_url( ss, intf_sys, client );
+    else if ( token == "thumbnail" )
+        return parse_thumbnail_url( ss, intf_sys );
+    else if ( token == "subtitle" )
+        return parse_subtitle_url( ss, intf_sys );
+    return nullptr;
+}
diff --git a/modules/control/upnp_server/FileHandler.hpp b/modules/control/upnp_server/FileHandler.hpp
new file mode 100644
index 0000000000..83ec9aeac3
--- /dev/null
+++ b/modules/control/upnp_server/FileHandler.hpp
@@ -0,0 +1,54 @@
+/*****************************************************************************
+ * FileHandler.hpp : UPnP server module header
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef FILEHANDLER_HPP
+#define FILEHANDLER_HPP
+
+#include "upnp_server.hpp"
+
+/// This interface reflects the common behaviour of upnp's file handlers.
+/// The FileHandler is used to serve the content of a named file to the http server.
+/// The file can be whatever: present on the fs, live streamed, etc.
+struct FileHandler
+{
+    virtual bool get_info( UpnpFileInfo& info ) noexcept = 0;
+    virtual bool open(intf_thread_t *) noexcept = 0;
+    virtual size_t read( uint8_t[], size_t ) noexcept = 0;
+
+    enum class SeekType : int
+    {
+        Set = SEEK_SET,
+        Current = SEEK_CUR,
+        End = SEEK_END
+    };
+    virtual bool seek( SeekType, off_t ) noexcept = 0;
+
+    virtual ~FileHandler() = default;
+};
+
+/// Parses the url and return the needed FileHandler implementation
+/// All the informations about what FileHandler implementation is to be chosen is locatied either in
+/// the url or the client's profile
+std::unique_ptr<FileHandler>
+parse_url( const char* url, const ClientProfile&, const intf_sys_t& ) noexcept;
+
+#endif /* FILEHANDLER_HPP */
diff --git a/modules/control/upnp_server/Option.hpp b/modules/control/upnp_server/Option.hpp
new file mode 100644
index 0000000000..be1cd2f386
--- /dev/null
+++ b/modules/control/upnp_server/Option.hpp
@@ -0,0 +1,126 @@
+/*****************************************************************************
+ * Option.hpp : std::optional replacement for c++14
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef OPTION_HPP
+#define OPTION_HPP
+
+
+#include <cassert>
+#include <exception>
+#include <string>
+#include <type_traits>
+
+#include <iostream>
+
+/// This is a straightforward and naive implementation of std::optional which is
+/// only available in c++17.
+/// TODO remplace all opt::Option instances by std::optional when we bump to c++17.
+
+namespace opt
+{
+
+struct nullopt_t
+{
+    // explicit ctor to support both "op = {};" and "op = nullopt;"
+    constexpr explicit nullopt_t( int ) {}
+};
+
+static constexpr nullopt_t nullopt( 0 );
+
+struct bad_optional_access : std::exception
+{
+    const char* what() const noexcept final { return "Bad optional access"; }
+};
+
+template <typename T>
+struct Option
+{
+    constexpr Option( nullopt_t ) : _has_value( false ) {}
+    constexpr Option( T&& value ) : _has_value( true ), _value( std::forward<T>( value ) )
+    {
+    }
+
+    constexpr Option() = default;
+    constexpr Option( const Option& other ) = default;
+
+    constexpr operator bool() const noexcept { return _has_value; }
+    constexpr bool has_value() const noexcept { return _has_value; }
+    constexpr bool operator==( nullopt_t ) noexcept { return !_has_value; }
+    constexpr bool operator!=( nullopt_t ) noexcept { return _has_value; }
+
+    constexpr T& value()
+    {
+        if ( not _has_value )
+            throw bad_optional_access{};
+        return _value;
+    }
+
+    constexpr const T& value() const
+    {
+        if ( not _has_value )
+            throw bad_optional_access{};
+        return _value;
+    }
+
+    template <typename U = T>
+    constexpr T value_or( U&& default_value ) const noexcept
+    {
+        return _has_value ? _value : static_cast<T>( std::forward<T>( default_value ) );
+    }
+
+    constexpr T* operator->() noexcept { return &_value; }
+    constexpr const T* operator->() const noexcept { return &_value; }
+    constexpr T& operator*() noexcept { return _value; }
+    constexpr const T& operator*() const noexcept { return _value; }
+
+    constexpr Option& operator=( T&& value )
+    {
+        _value = std::forward<T>( value );
+        _has_value = true;
+        return *this;
+    }
+
+    private:
+    bool _has_value = false;
+    T _value = {};
+};
+
+namespace
+{
+static_assert( std::is_trivially_copyable<opt::Option<int>>::value, "" );
+
+static_assert( !bool( Option<int>() ), "" );
+static_assert( !Option<int>().has_value(), "" );
+static_assert( Option<int>() == nullopt, "" );
+
+static_assert( bool( Option<int>( 42 ) ), "" );
+static_assert( Option<int>( 42 ).has_value(), "" );
+static_assert( Option<int>( 42 ) != nullopt, "" );
+
+static_assert( Option<int>( 42 ).value() == 42, "" );
+static_assert( Option<int>( 42 ).value() != 43, "" );
+
+static_assert( Option<int>( 42 ).value_or( 0 ) == 42, "" );
+} // namespace
+} // namespace opt
+
+#endif /* OPTION_HPP */
diff --git a/modules/control/upnp_server/Profiles.hpp b/modules/control/upnp_server/Profiles.hpp
new file mode 100644
index 0000000000..4491d3a5c6
--- /dev/null
+++ b/modules/control/upnp_server/Profiles.hpp
@@ -0,0 +1,57 @@
+/*****************************************************************************
+ * Profiles.hpp : Upnp clients profiles
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef PROFILES_HPP
+#define PROFILES_HPP
+
+#ifdef HAVE_CONFIG_H
+#include "config.h"
+#endif
+
+#include <vlc_common.h>
+
+#include <vector>
+#include <string>
+
+struct TranscodeProfile
+{
+    std::string name;
+    int conversion_quality; // see stream_out/renderer_common.hpp
+    int media_type;
+    const std::vector<vlc_fourcc_t> vcodecs_fallbacks;
+    vlc_fourcc_t acodec;
+    struct
+    {
+        const char* name;
+        const char* file_extension;
+        const char* dlna_profile_name;
+    } mux;
+};
+
+struct ClientProfile {
+  std::vector<TranscodeProfile> transcode_profiles;
+
+  // Set to false when the client only play the first media resource and ignore the others.
+  bool native_resource_first = true;
+};
+
+#endif /* PROFILES_HPP */
diff --git a/modules/control/upnp_server/cds/Container.hpp b/modules/control/upnp_server/cds/Container.hpp
new file mode 100644
index 0000000000..a3968cbc10
--- /dev/null
+++ b/modules/control/upnp_server/cds/Container.hpp
@@ -0,0 +1,77 @@
+/*****************************************************************************
+ * Container.hpp : CDS Container interface
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef CONTAINER_HPP
+#define CONTAINER_HPP
+
+#include "Object.hpp"
+
+namespace cds
+{
+/// Opaque CDS container type
+/// Specs: http://www.upnp.org/specs/av/UPnP-av-ContentDirectory-v3-Service-20080930.pdf
+/// "2.2.8 - Container"
+class Container : public Object
+{
+    protected:
+    size_t child_count;
+
+    public:
+    struct BrowseParams
+    {
+        unsigned offset;
+        unsigned requested;
+    };
+
+    struct BrowseStats
+    {
+        size_t result_count;
+        size_t total_matches;
+    };
+
+    Container( const int64_t id,
+               const int64_t parent_id,
+               const char* name,
+               size_t child_count ) noexcept
+        : Object( id, parent_id, name, Object::Type::Container )
+        , child_count( child_count )
+    {
+    }
+
+    virtual void dump_metadata( xml::Element& dest, const Object::ExtraId&, const ClientProfile&) const override
+    {
+        dest.set_attribute( "childCount", std::to_string( child_count ).c_str() );
+
+        xml::Document& doc = dest.owner;
+        dest.add_child(
+            doc.create_element( "upnp:class", doc.create_text_node( "object.container" ) ) );
+    }
+
+    /// Go through all the container children and dump them to the given xml element
+    virtual BrowseStats browse_direct_children( xml::Element&,
+                                                BrowseParams,
+                                                const Object::ExtraId&,
+                                                const ClientProfile& ) const = 0;
+};
+} // namespace cds
+
+#endif /* CONTAINER_HPP */
diff --git a/modules/control/upnp_server/cds/FixedContainer.cpp b/modules/control/upnp_server/cds/FixedContainer.cpp
new file mode 100644
index 0000000000..bd762f3434
--- /dev/null
+++ b/modules/control/upnp_server/cds/FixedContainer.cpp
@@ -0,0 +1,72 @@
+/*****************************************************************************
+ * FixedContainer.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "FixedContainer.hpp"
+
+#include <algorithm>
+
+namespace cds
+{
+
+FixedContainer::FixedContainer( const int64_t id,
+                                const int64_t parent_id,
+                                const char* name,
+                                std::initializer_list<ObjRef> children ) noexcept
+    : Container( id, parent_id, name, children.size() )
+    , children( children )
+{
+}
+
+FixedContainer::FixedContainer( const int64_t id,
+                                const int64_t parent_id,
+                                const char* name) noexcept
+    : Container( id, parent_id, name, 0 )
+{
+}
+
+Container::BrowseStats FixedContainer::browse_direct_children( xml::Element& dest,
+                                                               BrowseParams params,
+                                                               const Object::ExtraId& extra,
+                                                               const ClientProfile& client) const
+{
+    assert( child_count == children.size() );
+    params.requested =
+        std::min( static_cast<size_t>( params.offset ) + params.requested, children.size() );
+
+    unsigned i = 0;
+    for ( ; i + params.offset < params.requested; ++i )
+    {
+        const Object& child = children.at( i + params.offset );
+        dest.add_child( child.browse_metadata( dest.owner, extra, client) );
+    }
+    return { i, child_count };
+}
+
+void FixedContainer::add_children( std::initializer_list<ObjRef> l )
+{
+    for ( Object& child : l )
+    {
+        child.parent_id = id;
+    }
+    children.insert( children.end(), l.begin(), l.end() );
+    child_count = children.size();
+}
+} // namespace cds
diff --git a/modules/control/upnp_server/cds/FixedContainer.hpp b/modules/control/upnp_server/cds/FixedContainer.hpp
new file mode 100644
index 0000000000..cfc60719ed
--- /dev/null
+++ b/modules/control/upnp_server/cds/FixedContainer.hpp
@@ -0,0 +1,55 @@
+/*****************************************************************************
+ * FixedContainer.hpp : Simple Container implementation
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef FIXEDCONTAINER_HPP
+#define FIXEDCONTAINER_HPP
+
+#include "Container.hpp"
+
+#include <vector>
+
+namespace cds {
+/// Simplest Container implementation, it is fixed in the object hierarchy and simply list other
+/// Objects
+struct FixedContainer : public Container
+{
+    using ObjRef = std::reference_wrapper<Object>;
+    FixedContainer( const int64_t id,
+                    const int64_t parent_id,
+                    const char* name,
+                    std::initializer_list<ObjRef> ) noexcept;
+    FixedContainer( const int64_t id,
+                    const int64_t parent_id,
+                    const char* name) noexcept;
+
+    BrowseStats browse_direct_children( xml::Element&,
+                                        BrowseParams,
+                                        const Object::ExtraId&,
+                                        const ClientProfile& ) const final;
+
+    void add_children(std::initializer_list<ObjRef> l);
+    private:
+    std::vector<ObjRef> children;
+  };
+}
+
+#endif /* FIXEDCONTAINER_HPP */
diff --git a/modules/control/upnp_server/cds/Item.cpp b/modules/control/upnp_server/cds/Item.cpp
new file mode 100644
index 0000000000..d7073e3321
--- /dev/null
+++ b/modules/control/upnp_server/cds/Item.cpp
@@ -0,0 +1,246 @@
+/*****************************************************************************
+ * Item.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "Item.hpp"
+
+#include "../ml.hpp"
+#include "../utils.hpp"
+
+#include "../stream_out/renderer_common.hpp"
+#include "../stream_out/dlna/dlna.hpp"
+
+#include <chrono>
+
+namespace cds
+{
+
+Item::Item( const int64_t id, vlc_medialibrary_t& ml ) noexcept
+    : Object( id, -1, nullptr, Object::Type::Item )
+    , m_medialib( ml )
+{
+}
+
+static bool should_propose_transcode( const vlc_ml_media_t& media,
+                              const TranscodeProfile& profile,
+                              const std::vector<utils::MediaTrackRef>& v_tracks)
+{
+    if ( media.i_type == VLC_ML_MEDIA_TYPE_VIDEO )
+    {
+        if ( profile.media_type != VLC_ML_MEDIA_TYPE_VIDEO )
+            return false;
+        assert(!profile.vcodecs_fallbacks.empty());
+        for ( const vlc_ml_media_track_t& vtrack : v_tracks )
+        {
+            switch ( profile.conversion_quality )
+            {
+            case CONVERSION_QUALITY_HIGH:
+            case CONVERSION_QUALITY_MEDIUM:
+                return vtrack.v.i_width >= 1920;
+            default:
+                return vtrack.v.i_width >= 1280;
+            }
+        }
+    }
+    return profile.media_type == VLC_ML_MEDIA_TYPE_AUDIO;
+}
+
+static void dump_resources( xml::Element& dest,
+                            const vlc_ml_media_t& media,
+                            const ClientProfile& client_profile,
+                            const std::string& file_extension)
+{
+
+
+    xml::Document& doc = dest.owner;
+
+    const auto v_tracks = utils::get_media_tracks(media, VLC_ML_TRACK_TYPE_VIDEO);
+    const auto main_files = utils::get_media_files(media, VLC_ML_FILE_TYPE_MAIN);
+
+    const std::string url_base = utils::get_server_url();
+    const auto media_duration =
+        utils::duration_to_string( "%H:%M:%S", std::chrono::milliseconds( media.i_duration) );
+
+    const auto make_resource = [&]( const TranscodeProfile* profile ) -> xml::Element {
+        const auto& profile_name = profile ? profile->name : "native";
+        const char* mux = profile ? profile->mux.file_extension : file_extension.c_str();
+
+        const std::string url =
+            url_base + "media/" + profile_name + "/" + std::to_string( media.i_id ) + "." + mux;
+        auto elem = doc.create_element( "res", doc.create_text_node( url.c_str() ) );
+
+        elem.set_attribute( "duration", media_duration.c_str() );
+
+        if ( media.i_type == VLC_ML_MEDIA_TYPE_VIDEO )
+        {
+            std::stringstream resolution;
+
+            if ( profile )
+            {
+                switch ( profile->conversion_quality )
+                {
+                case CONVERSION_QUALITY_HIGH:
+                case CONVERSION_QUALITY_MEDIUM:
+                    resolution << "1920x1080";
+                    break;
+                default:
+                    resolution << "1280x720";
+                    break;
+                }
+            }
+            else
+            {
+                assert( v_tracks.size() >= 1 );
+                const vlc_ml_media_track_t& vtrack = v_tracks[0];
+
+                resolution << vtrack.v.i_width << 'x' << vtrack.v.i_height;
+            }
+            elem.set_attribute( "resolution", resolution.str().c_str() );
+        }
+
+        const auto mime_type = utils::get_mimetype( media.i_type, mux );
+        const auto protocol_info =
+            std::string( "http-get:*:" ) + mime_type.combine() + ":" +
+            utils::get_dlna_extra_protocol_info( mime_type.file_type, profile );
+
+        elem.set_attribute( "protocolInfo", protocol_info.c_str() );
+
+        if ( !profile )
+        {
+            assert( main_files.size() >= 1 );
+            elem.set_attribute( "size", std::to_string( main_files[0].get().i_size ).c_str() );
+        }
+
+
+        return elem;
+    };
+
+    // Native resource (no transcode) first
+    if ( client_profile.native_resource_first )
+        dest.add_child( make_resource( nullptr ) );
+
+    // Transcoded resources
+    for ( const auto& profile : client_profile.transcode_profiles )
+    {
+        if ( should_propose_transcode( media, profile, v_tracks ) )
+            dest.add_child( make_resource( &profile ) );
+    }
+
+    // Native resource (no transcode) last
+    if ( !client_profile.native_resource_first )
+        dest.add_child( make_resource( nullptr ) );
+
+    // Thumbnails
+    for ( int i = 0; i < VLC_ML_THUMBNAIL_SIZE_COUNT; ++i )
+    {
+        const auto& thumbnail = media.thumbnails[i];
+        if ( thumbnail.i_status != VLC_ML_THUMBNAIL_STATUS_AVAILABLE )
+            continue;
+        const auto thumbnail_extension = utils::file_extension( std::string( thumbnail.psz_mrl ) );
+        const auto url = utils::thumbnail_url( media, static_cast<vlc_ml_thumbnail_size_t>( i ) );
+        auto elem = doc.create_element( "res", doc.create_text_node( url.c_str() ) );
+
+        const auto dlna_extra = utils::get_dlna_extra_protocol_info( thumbnail_extension, nullptr );
+        const auto protocol_info =
+            std::string( "http-get:*:image/" ) + thumbnail_extension + ":" + dlna_extra;
+        elem.set_attribute( "protocolInfo", protocol_info.c_str() );
+
+        dest.add_child( std::move( elem ) );
+    }
+
+    // Subtitles, for now we only share the first available subtitle file.
+    const auto subtitles = utils::get_media_files(media, VLC_ML_FILE_TYPE_SUBTITLE);
+    if (!subtitles.empty())
+    {
+      const vlc_ml_file_t& sub = subtitles.front();
+      const auto file_extension = utils::file_extension( sub.psz_mrl );
+      const std::string url = utils::get_server_url() + "subtitle/" + std::to_string( media.i_id ) +
+                              "." + file_extension;
+
+      auto res = doc.create_element( "res", doc.create_text_node( url.c_str() ) );
+      res.set_attribute( "protocolInfo", ( "http-get:*:text/" + file_extension + ":*" ).c_str() );
+      dest.add_child( std::move( res ) );
+    }
+
+}
+
+void Item::dump_mlobject_metadata( xml::Element& dest,
+                                   const vlc_ml_media_t& media,
+                                   vlc_medialibrary_t& ml,
+                                   const ClientProfile& profile ) noexcept
+{
+
+    assert( media.p_files->i_nb_items >= 1 );
+    const vlc_ml_file_t& file = media.p_files->p_items[0];
+
+    const std::string file_extension = utils::file_extension( file.psz_mrl );
+
+    const char* object_class = nullptr;
+    if ( media.i_type == VLC_ML_MEDIA_TYPE_AUDIO )
+    {
+        object_class = "object.item.audioItem";
+    }
+    else if ( media.i_type == VLC_ML_MEDIA_TYPE_VIDEO )
+    {
+        object_class = "object.item.videoItem";
+    }
+    assert( object_class != nullptr );
+
+    const std::string date = std::to_string( media.i_year ) + "-01-01";
+
+    xml::Document& doc = dest.owner;
+
+    dest.add_children( doc.create_element( "upnp:class", doc.create_text_node( object_class ) ),
+                       doc.create_element( "dc:title", doc.create_text_node( media.psz_title ) ),
+                       doc.create_element( "dc:date", doc.create_text_node( date.c_str() ) ) );
+    switch ( media.i_subtype )
+    {
+    case VLC_ML_MEDIA_SUBTYPE_ALBUMTRACK:
+    {
+        const auto album = ml::Album::get( ml, media.album_track.i_album_id );
+        if ( album != nullptr )
+        {
+            const auto album_thumbnail_url = utils::album_thumbnail_url( *album );
+            dest.add_children(
+                doc.create_element( "upnp:album", doc.create_text_node( album->psz_title ) ),
+                doc.create_element( "upnp:artist", doc.create_text_node( album->psz_artist ) ),
+                doc.create_element( "upnp:albumArtURI",
+                                    doc.create_text_node( album_thumbnail_url.c_str() ) ) );
+        }
+        break;
+    }
+    default:
+        break;
+    }
+
+    dump_resources( dest, media, profile, file_extension );
+}
+
+void Item::dump_metadata( xml::Element& dest,
+                          const Object::ExtraId& extra_id,
+                          const ClientProfile& profile ) const
+{
+    assert( extra_id.has_value() );
+    const auto media = ml::Media::get( m_medialib, extra_id->ml_id );
+
+    dump_mlobject_metadata( dest, *media.get(), m_medialib, profile );
+}
+
+} // namespace cds
diff --git a/modules/control/upnp_server/cds/Item.hpp b/modules/control/upnp_server/cds/Item.hpp
new file mode 100644
index 0000000000..4369dc1696
--- /dev/null
+++ b/modules/control/upnp_server/cds/Item.hpp
@@ -0,0 +1,55 @@
+/*****************************************************************************
+ * Item.hpp : CDS Item interface
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef ITEM_HPP
+#define ITEM_HPP
+
+#ifdef HAVE_CONFIG_H
+#include "config.h"
+#endif
+
+#include <vlc_common.h>
+#include <vlc_media_library.h>
+
+#include "Object.hpp"
+
+namespace cds
+{
+/// This is a dynamic object representing a medialibrary media
+/// It expect to receive the medialibrary id of the media it should represent in its Extra ID, with
+/// that, a single instance of Item can effectively represent all medialibrary medias
+class Item : public Object
+{
+    vlc_medialibrary_t& m_medialib;
+
+    public:
+    Item( const int64_t id, vlc_medialibrary_t& ) noexcept;
+    void dump_metadata( xml::Element&, const Object::ExtraId&, const ClientProfile& ) const final;
+
+    static void dump_mlobject_metadata( xml::Element& dest,
+                                        const vlc_ml_media_t& media,
+                                        vlc_medialibrary_t& ml,
+                                        const ClientProfile& ) noexcept;
+};
+} // namespace cds
+
+#endif /* ITEM_HPP */
diff --git a/modules/control/upnp_server/cds/MLContainer.hpp b/modules/control/upnp_server/cds/MLContainer.hpp
new file mode 100644
index 0000000000..33f4318102
--- /dev/null
+++ b/modules/control/upnp_server/cds/MLContainer.hpp
@@ -0,0 +1,202 @@
+/*****************************************************************************
+ * MLContainer.hpp : CDS MediaLibrary container implementation
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef MLCONTAINER_HPP
+#define MLCONTAINER_HPP
+
+#include "Container.hpp"
+
+#include <vlc_media_library.h>
+#include <vlc_cxx_helpers.hpp>
+#include <vlc_url.h>
+
+#include "../utils.hpp"
+#include "Item.hpp"
+
+namespace cds
+{
+
+/// MLContainer is a dynamic object, it must have a ml id in its extra id.
+/// MLContainer is a very versatile Container that basically list all the medialibrary objects such
+/// as Albums, Playlists, etc.
+/// MLHelpers can be found in "../ml.hpp"
+template <typename MLHelper>
+class MLContainer : public Container
+{
+    private:
+    vlc_medialibrary_t& ml;
+    /// We take another dynamic object as member, this will be the dynamic child of the
+    /// MLContainer, for example a MLContainer representing an album will have an Item
+    /// ("./Item.hpp") as child
+    const Object& child;
+
+    public:
+    MLContainer( int64_t id,
+                 int64_t parent_id,
+                 const char* name,
+                 vlc_medialibrary_t& ml,
+                 const Object& child ) noexcept
+        : Container( id, parent_id, name, MLHelper::count( ml, opt::nullopt ) )
+        , ml( ml )
+        , child( child )
+    {
+    }
+
+    void dump_metadata( xml::Element& dest, const Object::ExtraId& extra, const ClientProfile& profile ) const final
+    {
+        if ( extra.has_value() )
+        {
+            const auto& ml_object = MLHelper::get( ml, extra.value().ml_id );
+            dump_mlobject_metadata( dest, *ml_object.get(), profile);
+        }
+        else
+        {
+            Container::dump_metadata( dest, opt::nullopt, profile );
+        }
+    }
+
+    BrowseStats browse_direct_children( xml::Element& dest,
+                                        const BrowseParams params,
+                                        const Object::ExtraId& extra, const ClientProfile& profile ) const final
+    {
+        const vlc_ml_query_params_t query_params = { nullptr, params.requested, params.offset,
+                                                     VLC_ML_SORTING_DEFAULT, true };
+        opt::Option<int64_t> ml_id;
+        if ( extra.has_value() )
+            ml_id = static_cast<int64_t>( extra->ml_id );
+        const auto list = MLHelper::list( ml, &query_params, ml_id );
+
+        xml::Document& doc = dest.owner;
+        for ( unsigned i = 0; i < list->i_nb_items; ++i )
+        {
+            const auto& item = list->p_items[i];
+
+            auto elem = child.create_object_element(
+                doc, ExtraIdData{ item.i_id, get_dynamic_id( extra ) } );
+            dump_mlobject_metadata( elem, item, profile );
+            dest.add_child( std::move( elem ) );
+        }
+        return { list->i_nb_items, MLHelper::count( ml, ml_id ) };
+    }
+
+    private:
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_media_t& media,
+                                 const ClientProfile& profile ) const
+    {
+        Item::dump_mlobject_metadata( dest, media, ml, profile );
+    }
+
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_playlist_t& playlist,
+                                 const ClientProfile& ) const
+    {
+        xml::Document& doc = dest.owner;
+
+        // TODO this is not exposed yet in the medialibrary
+        // dest.set_attribute( "childCount", std::to_string( playlist.i_nb_tracks ).c_str() );
+
+        dest.add_children(
+            doc.create_element( "upnp:class",
+                                doc.create_text_node( "object.container.playlistContainer" ) ),
+            doc.create_element( "dc:title", doc.create_text_node( playlist.psz_name ) ) );
+    }
+
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_album_t& album,
+                                 const ClientProfile& ) const
+    {
+        xml::Document& doc = dest.owner;
+
+        dest.set_attribute( "childCount", std::to_string( album.i_nb_tracks ).c_str() );
+
+        const auto album_thumbnail_url = utils::album_thumbnail_url( album );
+        dest.add_children(
+            doc.create_element( "upnp:artist", doc.create_text_node( album.psz_artist ) ),
+            doc.create_element( "upnp:class",
+                                doc.create_text_node( "object.container.album.musicAlbum" ) ),
+            doc.create_element( "dc:title", doc.create_text_node( album.psz_title ) ),
+            doc.create_element( "dc:description", doc.create_text_node( album.psz_summary ) ),
+            doc.create_element( "upnp:albumArtURI",
+                                doc.create_text_node( album_thumbnail_url.c_str() ) ) );
+    }
+
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_artist_t& artist,
+                                 const ClientProfile& ) const
+    {
+        xml::Document& doc = dest.owner;
+
+        dest.set_attribute( "childCount", std::to_string( artist.i_nb_album ).c_str() );
+
+        dest.add_children(
+            doc.create_element( "upnp:class",
+                                doc.create_text_node( "object.container.person.musicArtist" ) ),
+            doc.create_element( "dc:title", doc.create_text_node( artist.psz_name ) ) );
+    }
+
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_genre_t& genre,
+                                 const ClientProfile& ) const
+    {
+        xml::Document& doc = dest.owner;
+
+        dest.set_attribute( "childCount", std::to_string( genre.i_nb_tracks ).c_str() );
+
+        dest.add_children(
+            doc.create_element( "upnp:class",
+                                doc.create_text_node( "object.container.genre.musicGenre" ) ),
+            doc.create_element( "dc:title", doc.create_text_node( genre.psz_name ) ) );
+    }
+
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_show_t& show,
+                                 const ClientProfile& ) const
+    {
+        xml::Document& doc = dest.owner;
+
+        const std::string nb_episodes = std::to_string( show.i_nb_episodes );
+        dest.set_attribute( "childCount", nb_episodes.c_str() );
+        dest.add_children(
+            doc.create_element( "upnp:class",
+                                doc.create_text_node( "object.container.playlistContainer" ) ),
+            doc.create_element( "dc:title", doc.create_text_node( show.psz_name ) ),
+            doc.create_element( "upnp:episodeCount",
+                                doc.create_text_node( nb_episodes.c_str() ) ) );
+    }
+    void dump_mlobject_metadata( xml::Element& dest,
+                                 const vlc_ml_folder_t& folder,
+                                 const ClientProfile& ) const
+    {
+        xml::Document& doc = dest.owner;
+
+        assert( !folder.b_banned );
+
+        const auto path = vlc::wrap_cptr( vlc_uri2path( folder.psz_mrl ), &free );
+        dest.add_children(
+            doc.create_element( "upnp:class", doc.create_text_node( "object.container" ) ),
+            doc.create_element( "dc:title", doc.create_text_node( path.get() ) ) );
+    }
+};
+} // namespace cds
+
+#endif /* MLCONTAINER_HPP */
diff --git a/modules/control/upnp_server/cds/MLFolderContainer.hpp b/modules/control/upnp_server/cds/MLFolderContainer.hpp
new file mode 100644
index 0000000000..c7df3bc96c
--- /dev/null
+++ b/modules/control/upnp_server/cds/MLFolderContainer.hpp
@@ -0,0 +1,152 @@
+/*****************************************************************************
+ * MLFolderContainer.hpp : MediaLibrary IFolder container
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef MLFOLDERCONTAINER_HPP
+#define MLFOLDERCONTAINER_HPP
+
+#include "Container.hpp"
+#include "Item.hpp"
+
+#include <vlc_media_library.h>
+#include <vlc_url.h>
+#include <vlc_cxx_helpers.hpp>
+
+#include "../ml.hpp"
+#include "../utils.hpp"
+
+namespace cds
+{
+class MLFolderContainer : public Container
+{
+    vlc_medialibrary_t& ml;
+    const Item& child;
+
+    static void dump_folder_metadata(xml::Element& dest, const vlc_ml_folder_t& folder) {
+        xml::Document& doc = dest.owner;
+        auto path = vlc::wrap_cptr(vlc_uri2path(folder.psz_mrl), &free);
+
+        // Only keep the last folder from the path
+        const char *folder_name = nullptr;
+        if ( path != nullptr && strlen( path.get() ) > 0 )
+        {
+#ifdef _WIN32
+            const char sep = '\\';
+#else
+            const char sep = '/';
+#endif
+
+            for ( auto i = strlen( path.get() ) - 1; i > 0; --i )
+            {
+                if ( path.get()[i] == sep )
+                    path.get()[i] = '\0';
+                else
+                    break;
+            }
+            folder_name = strrchr( path.get(), '/' ) + 1;
+        }
+
+        dest.add_children(
+            doc.create_element( "dc:title", doc.create_text_node( folder_name ? folder_name : path.get() ) ),
+            doc.create_element( "upnp:class", doc.create_text_node( "object.container" ) ) );
+    }
+
+    public:
+    MLFolderContainer( int64_t id,
+                       int64_t parent_id,
+                       const char* name,
+                       vlc_medialibrary_t& ml,
+                       const Item& child )
+        : Container( id, parent_id, name, 0 )
+        , ml( ml )
+        , child( child )
+    {
+    }
+
+    void dump_metadata( xml::Element& dest, const Object::ExtraId& extra, const ClientProfile& profile ) const final
+    {
+        if ( !extra.has_value() )
+        {
+            Container::dump_metadata( dest, opt::nullopt, profile);
+            return;
+        }
+        const auto folder = ml::Folder::get( ml, extra->ml_id );
+
+        if ( folder != nullptr )
+        {
+          dump_folder_metadata(dest, *folder);
+        }
+    }
+
+    BrowseStats browse_direct_children( xml::Element& dest,
+                                        const BrowseParams params,
+                                        const Object::ExtraId& extra,
+                                        const ClientProfile& profile ) const final
+    {
+        const vlc_ml_query_params_t query_params = { nullptr, params.requested, params.offset,
+                                                     VLC_ML_SORTING_DEFAULT, true };
+        assert( extra.has_value() );
+
+        const auto folder = ml::Folder::get( ml, extra->ml_id );
+        assert( folder != nullptr );
+
+        xml::Document& doc = dest.owner;
+
+        vlc_ml_folder_list_t* list = nullptr;
+        auto status =
+            vlc_ml_list( &ml, VLC_ML_LIST_SUBFOLDERS, &query_params, extra->ml_id, &list );
+        assert( status == VLC_SUCCESS );
+        for ( auto i = 0u; i < list->i_nb_items; ++i )
+        {
+            const auto& folder = list->p_items[i];
+            auto elem = create_object_element(
+                doc, ExtraIdData{ folder.i_id, get_dynamic_id( extra ) } );
+            dump_folder_metadata(elem, folder);
+            dest.add_child( std::move( elem ) );
+        }
+
+        const size_t listed_subfolders = list->i_nb_items;
+        vlc_ml_release( list );
+
+        size_t total_subfolders;
+        status = vlc_ml_list( &ml, VLC_ML_COUNT_SUBFOLDERS, &query_params, extra->ml_id, &total_subfolders );
+
+
+        const auto media_list = ml::MediaFolderList::list(ml, &query_params, int64_t(extra->ml_id));
+
+        if ( media_list )
+        {
+            for ( auto i = 0u; i < media_list->i_nb_items; ++i )
+            {
+                const auto& media = media_list->p_items[i];
+                auto elem = child.create_object_element(
+                    doc, ExtraIdData{ child.id, get_dynamic_id( extra ) } );
+                Item::dump_mlobject_metadata( elem, media, ml, profile );
+                dest.add_child( std::move( elem ) );
+            }
+        }
+
+        return { listed_subfolders, total_subfolders };
+    }
+};
+} // namespace cds
+
+#endif /* MLFOLDERCONTAINER_HPP */
diff --git a/modules/control/upnp_server/cds/Object.hpp b/modules/control/upnp_server/cds/Object.hpp
new file mode 100644
index 0000000000..1c7e53a0f5
--- /dev/null
+++ b/modules/control/upnp_server/cds/Object.hpp
@@ -0,0 +1,123 @@
+/*****************************************************************************
+ * Object.hpp : CDS Object interface implementation
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef OBJECT_HPP
+#define OBJECT_HPP
+
+#include "../Option.hpp"
+#include "../xml_wrapper.hpp"
+
+#include "../Profiles.hpp"
+
+namespace cds
+{
+/// Opaque CDS object type
+/// Specs: http://www.upnp.org/specs/av/UPnP-av-ContentDirectory-v3-Service-20080930.pdf
+/// "2.2.2 - Object"
+struct Object
+{
+    enum class Type
+    {
+        Item,
+        Container
+    };
+
+    struct ExtraIdData
+    {
+        int64_t ml_id;
+        std::string parent;
+    };
+
+    using ExtraId = opt::Option<ExtraIdData>;
+
+    int64_t id;
+    int64_t parent_id;
+    const char* name;
+    const Type type;
+
+    Object( int64_t id, int64_t parent_id, const char* name, const Type type ) noexcept
+        : id( id )
+        , parent_id( parent_id )
+        , name( name )
+        , type( type )
+    {
+    }
+    virtual ~Object() = default;
+
+    /// Create an xml element describing the object.
+    xml::Element browse_metadata( xml::Document& doc,
+                                  const ExtraId& extra_id,
+                                  const ClientProfile& profile ) const
+    {
+        auto ret = create_object_element( doc, extra_id );
+
+        dump_metadata( ret, extra_id, profile );
+
+        return ret;
+    }
+
+    /// Utility function to create the xml common representation of an object.
+    xml::Element create_object_element( xml::Document& doc, const ExtraId& extra_id ) const
+    {
+        auto ret = doc.create_element( type == Type::Item ? "item" : "container" );
+
+        if ( extra_id.has_value() )
+        {
+            ret.set_attribute( "id", get_dynamic_id( extra_id ).c_str() );
+            ret.set_attribute( "parentID", extra_id->parent.c_str() );
+        }
+        else
+        {
+            ret.set_attribute( "id", std::to_string(id).c_str() );
+            ret.set_attribute( "parentID", std::to_string(parent_id).c_str() );
+        }
+
+        if ( name )
+        {
+            ret.add_child( doc.create_element( "dc:title", doc.create_text_node( name ) ) );
+        }
+        ret.set_attribute( "restricted", "1" );
+        return ret;
+    }
+
+    protected:
+    /// Build an Object id based on the extra id provided,
+    /// Some Objects can have a changing id based on the medialib id they expose, for example,
+    /// "1:3" or "1:43" are both valid, they just expose different contents through the same object
+    /// tied to the id "1".
+    std::string get_dynamic_id( const ExtraId& extra_id ) const noexcept
+    {
+        if ( !extra_id.has_value() )
+            return std::to_string( id );
+        const std::string ml_id_str = std::to_string( extra_id->ml_id );
+        if (!extra_id->parent.empty())
+          return std::to_string( id ) + ':' + ml_id_str + '(' + extra_id->parent + ')';
+        return std::to_string( id ) + ':' + ml_id_str;
+    }
+    /// Dump Object specialization specific informations in the fiven xml element
+    virtual void dump_metadata( xml::Element& dest,
+                                const ExtraId& extra_id,
+                                const ClientProfile& profile ) const = 0;
+};
+} // namespace cds
+
+#endif /* OBJECT_HPP */
diff --git a/modules/control/upnp_server/cds/cds.cpp b/modules/control/upnp_server/cds/cds.cpp
new file mode 100644
index 0000000000..12731b1ec8
--- /dev/null
+++ b/modules/control/upnp_server/cds/cds.cpp
@@ -0,0 +1,153 @@
+/*****************************************************************************
+ * cds.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "cds.hpp"
+
+#include "FixedContainer.hpp"
+#include "Item.hpp"
+#include "MLContainer.hpp"
+
+#include "MLFolderContainer.hpp"
+
+namespace cds
+{
+
+template <typename T>
+opt::Option<T> next_value( std::stringstream& ss, const char delim )
+{
+    std::string token;
+    if ( !std::getline( ss, token, delim ) )
+        return opt::nullopt;
+    try
+    {
+        return static_cast<T>( std::stoull( token ) );
+    }
+    catch ( const std::invalid_argument& )
+    {
+        return opt::nullopt;
+    }
+};
+
+std::tuple<unsigned, Object::ExtraId> parse_id( const std::string& id )
+{
+    std::stringstream ss( id );
+    const auto parsed_id = next_value<unsigned>( ss, ':' );
+    if ( !parsed_id.has_value() )
+        throw std::invalid_argument( "Invalid id" );
+    const auto parsed_ml_id = next_value<int64_t>( ss, '(' );
+
+    opt::Option<std::string> parent = opt::nullopt;
+    {
+        std::string token;
+        if ( std::getline( ss, token ) && !token.empty() && token.back() == ')' )
+            parent = token.substr( 0, token.size() - 1 );
+    }
+
+    if ( parsed_ml_id.has_value() )
+        return { parsed_id.value(), { { parsed_ml_id.value(), parent.value_or( "" ) } } };
+    return { parsed_id.value(), opt::nullopt };
+}
+
+std::vector<std::unique_ptr<Object>>
+init_hierarchy( vlc_medialibrary_t& ml )
+{
+    std::vector<std::unique_ptr<Object>> hierarchy;
+
+    const auto add_fixed_container =
+        [&]( const char* name, std::initializer_list<FixedContainer::ObjRef> children ) -> Object& {
+        const int64_t id = hierarchy.size();
+        for ( Object& child : children )
+            child.parent_id = id;
+        auto up = std::make_unique<FixedContainer>( id, -1, name, children );
+        hierarchy.emplace_back( std::move( up ) );
+        return *hierarchy.back();
+    };
+
+    const auto add_ml_container = [&]( auto MLHelper, const char* name, Object& child ) -> Object& {
+        const int64_t id = hierarchy.size();
+        child.parent_id = id;
+        hierarchy.push_back( std::make_unique<MLContainer<decltype( MLHelper )>>(
+            id, -1, name, ml, child ) );
+        return static_cast<Object&>( *hierarchy.back() );
+    };
+
+    hierarchy.push_back( std::make_unique<FixedContainer>( 0, -1, "Home" ) );
+    hierarchy.push_back( std::make_unique<Item>( 1, ml ) );
+
+    const auto& item = static_cast<const Item&>( *hierarchy[1] );
+
+
+    const auto add_ml_folder_container = [&]( ) -> Object& {
+        const int64_t id = hierarchy.size();
+        hierarchy.push_back( std::make_unique<MLFolderContainer>(
+            id, -1, nullptr, ml, item ) );
+        return static_cast<Object&>( *hierarchy.back() );
+    };
+
+    const auto add_ml_media_container = [&]( auto MLHelper, const char* name ) -> Object& {
+        const int64_t id = hierarchy.size();
+        hierarchy.push_back( std::make_unique<MLContainer<decltype( MLHelper )>>(
+            id, -1, name, ml, item ) );
+        return static_cast<Object&>( *hierarchy.back() );
+    };
+
+    static_cast<FixedContainer&>( *hierarchy[0] )
+        .add_children( {
+            add_fixed_container(
+                "Video",
+                { add_ml_media_container( ml::AllVideos{}, "All Video" ),
+                  add_ml_container( ml::ShowsList{}, "Shows",
+                                    add_ml_media_container( ml::ShowsMediaList{}, nullptr ) ) } ),
+            add_fixed_container(
+                "Music",
+                {
+                    add_ml_media_container( ml::AllAudio{}, "All Audio" ),
+                    add_ml_container( ml::AllAlbums{}, "Albums",
+                                      add_ml_media_container( ml::AlbumTracksList{}, nullptr ) ),
+                    add_fixed_container(
+                        "Artists",
+                        { add_ml_container(
+                              ml::AllArtistsList{}, "By Tracks",
+                              add_ml_media_container( ml::ArtistTracksList{}, nullptr ) ),
+                          add_ml_container(
+                              ml::AllArtistsList{}, "By Albums",
+                              add_ml_container(
+                                  ml::ArtistAlbumList{}, nullptr,
+                                  add_ml_media_container( ml::AlbumTracksList{}, nullptr ) ) ) } ),
+                    add_fixed_container(
+                        "Genres",
+                        { add_ml_container(
+                              ml::AllGenresList{}, "By Tracks",
+                              add_ml_media_container( ml::GenreTracksList{}, nullptr ) ),
+                          add_ml_container(
+                              ml::AllGenresList{}, "By Albums",
+                              add_ml_container(
+                                  ml::GenreAlbumList{}, nullptr,
+                                  add_ml_media_container( ml::AlbumTracksList{}, nullptr ) ) ) } ),
+                } ),
+            add_ml_container( ml::PlaylistsList{}, "Playlists",
+                              add_ml_media_container( ml::PlaylistMediaList{}, nullptr ) ),
+            add_ml_container( ml::AllEntryPoints{}, "Entry points", add_ml_folder_container() ),
+        } );
+
+    return hierarchy;
+}
+} // namespace cds
diff --git a/modules/control/upnp_server/cds/cds.hpp b/modules/control/upnp_server/cds/cds.hpp
new file mode 100644
index 0000000000..3ac8c81fec
--- /dev/null
+++ b/modules/control/upnp_server/cds/cds.hpp
@@ -0,0 +1,56 @@
+/*****************************************************************************
+ * cds.hpp : UPNP ContentDirectory Service entry point
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef CDS_HPP
+#define CDS_HPP
+
+#include "Object.hpp"
+#include "../ml.hpp"
+
+#include <vector>
+
+/// CDS is the short for ContentDirectory Service, its the services that clients should use to
+/// browse the server file hierarchy
+///
+/// Specs: http://www.upnp.org/specs/av/UPnP-av-ContentDirectory-v3-Service-20080930.pdf
+namespace cds
+{
+
+/// Split the given string and return the cds::Object id its refers to along with extra
+/// informations on the object (for instance a medialibrary id).
+/// A string id must follow this pattern:
+///   "OBJ_ID:ML_ID(PARENT_ID)" where:
+///     - OBJ_ID is the cds object index.
+///     - ML_ID (optional) is a medialibrary id (A media, an album, whatever)
+///     - PARENT_ID (optional) is the cds parent object, it's needed in case the parent has a ML_ID
+///       bound to it
+/// This pattern allows us to create "dynamics" object that reflects the structure of the
+/// medialibrary database without actually duplicating it.
+std::tuple<unsigned, Object::ExtraId> parse_id( const std::string& id );
+
+/// Initialize the Upnp server objects hierarchy.
+/// This needs to be called once at the startup of the server.
+std::vector<std::unique_ptr<Object>> init_hierarchy( vlc_medialibrary_t& ml );
+
+} // namespace cds
+
+#endif /* CDS_HPP */
diff --git a/modules/control/upnp_server/ml.hpp b/modules/control/upnp_server/ml.hpp
new file mode 100644
index 0000000000..bec4e2cde5
--- /dev/null
+++ b/modules/control/upnp_server/ml.hpp
@@ -0,0 +1,144 @@
+/*****************************************************************************
+ * ml.hpp : C++ media library APi wrapper
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef ML_HPP
+#define ML_HPP
+
+#ifdef HAVE_CONFIG_H
+#include "config.h"
+#endif
+
+#include <vlc_common.h>
+#include <vlc_media_library.h>
+
+#include "Option.hpp"
+
+#include <functional>
+#include <memory>
+#include <type_traits>
+
+namespace ml
+{
+
+namespace
+{
+template <typename T>
+void ml_object_release( T* obj ) noexcept
+{
+    vlc_ml_release( obj );
+}
+} // namespace
+
+template <typename MLObject, vlc_ml_get_queries GetQuery>
+struct Object
+{
+    using Ptr = std::unique_ptr<MLObject, std::function<void( MLObject* )>>;
+    static Ptr get( vlc_medialibrary_t& ml, const int64_t id ) noexcept
+    {
+        MLObject* obj = static_cast<MLObject*>( vlc_ml_get( &ml, GetQuery, id ) );
+        return Ptr{ obj, &ml_object_release<MLObject> };
+    }
+};
+
+using Media = Object<vlc_ml_media_t, VLC_ML_GET_MEDIA>;
+using Album = Object<vlc_ml_album_t, VLC_ML_GET_ALBUM>;
+using Playlist = Object<vlc_ml_playlist_t, VLC_ML_GET_PLAYLIST>;
+using Show = Object<vlc_ml_show_t, VLC_ML_GET_SHOW>;
+using Artist = Object<vlc_ml_artist_t, VLC_ML_GET_ARTIST>;
+using Genre = Object<vlc_ml_artist_t, VLC_ML_GET_GENRE>;
+using Folder = Object<vlc_ml_folder_t, VLC_ML_GET_FOLDER>;
+
+template <typename ListType,
+          vlc_ml_list_queries ListQuery,
+          vlc_ml_list_queries CountQuery,
+          typename Object>
+struct List : Object
+{
+    static size_t count( vlc_medialibrary_t& ml, opt::Option<int64_t> id ) noexcept
+    {
+        size_t res;
+        int status;
+
+        if ( id.has_value() )
+            status = vlc_ml_list( &ml, CountQuery, nullptr, id.value(), &res );
+        else if ( CountQuery == VLC_ML_COUNT_ARTISTS )
+            status = vlc_ml_list( &ml, CountQuery, nullptr, (int)false, &res );
+        else if ( CountQuery == VLC_ML_COUNT_ENTRY_POINTS )
+            status = vlc_ml_list( &ml, CountQuery, nullptr, (int)false, &res );
+        else
+            status = vlc_ml_list( &ml, CountQuery, nullptr, &res );
+        return status == VLC_SUCCESS ? res : 0;
+    }
+
+    using Ptr = std::unique_ptr<ListType, std::function<void( ListType* )>>;
+    static Ptr list( vlc_medialibrary_t& ml,
+                     const vlc_ml_query_params_t* params,
+                     const opt::Option<int64_t> id ) noexcept
+    {
+        ListType* res;
+        int status;
+
+        if ( id.has_value() )
+            status = vlc_ml_list( &ml, ListQuery, params, id.value(), &res );
+        else if ( ListQuery == VLC_ML_LIST_ARTISTS )
+            status = vlc_ml_list( &ml, ListQuery, params, (int)false, &res );
+        else if ( ListQuery == VLC_ML_LIST_ENTRY_POINTS )
+            status = vlc_ml_list( &ml, ListQuery, params, (int)false, &res );
+        else
+            status = vlc_ml_list( &ml, ListQuery, params, &res );
+        return { status == VLC_SUCCESS ? res : nullptr, &ml_object_release<ListType> };
+    }
+};
+
+using AllAudio = List<vlc_ml_media_list_t, VLC_ML_LIST_AUDIOS, VLC_ML_COUNT_AUDIOS, Media>;
+using AllVideos = List<vlc_ml_media_list_t, VLC_ML_LIST_VIDEOS, VLC_ML_COUNT_VIDEOS, Media>;
+using AllAlbums = List<vlc_ml_album_list_t, VLC_ML_LIST_ALBUMS, VLC_ML_COUNT_ALBUMS, Media>;
+using AllEntryPoints = List<vlc_ml_folder_list_t, VLC_ML_LIST_ENTRY_POINTS, VLC_ML_COUNT_ENTRY_POINTS, Folder>;
+
+using AllArtistsList = List<vlc_ml_artist_list_t, VLC_ML_LIST_ARTISTS, VLC_ML_COUNT_ARTISTS, Media>;
+using ArtistAlbumList =
+    List<vlc_ml_album_list_t, VLC_ML_LIST_ARTIST_ALBUMS, VLC_ML_COUNT_ARTIST_ALBUMS, Album>;
+using ArtistTracksList =
+    List<vlc_ml_media_list_t, VLC_ML_LIST_ARTIST_TRACKS, VLC_ML_COUNT_ARTIST_TRACKS, Media>;
+using AlbumTracksList =
+    List<vlc_ml_media_list_t, VLC_ML_LIST_ALBUM_TRACKS, VLC_ML_COUNT_ALBUM_TRACKS, Album>;
+
+using AllGenresList = List<vlc_ml_genre_list_t, VLC_ML_LIST_GENRES, VLC_ML_COUNT_GENRES, Media>;
+using GenreAlbumList =
+    List<vlc_ml_album_list_t, VLC_ML_LIST_GENRE_ALBUMS, VLC_ML_COUNT_GENRE_ALBUMS, Album>;
+using GenreTracksList =
+    List<vlc_ml_media_list_t, VLC_ML_LIST_GENRE_TRACKS, VLC_ML_COUNT_GENRE_TRACKS, Media>;
+
+using PlaylistsList =
+    List<vlc_ml_playlist_list_t, VLC_ML_LIST_PLAYLISTS, VLC_ML_COUNT_PLAYLISTS, Playlist>;
+using PlaylistMediaList =
+    List<vlc_ml_media_list_t, VLC_ML_LIST_PLAYLIST_MEDIA, VLC_ML_COUNT_PLAYLIST_MEDIA, Media>;
+
+using ShowsList = List<vlc_ml_show_list_t, VLC_ML_LIST_SHOWS, VLC_ML_COUNT_SHOW_EPISODES, Show>;
+using ShowsMediaList =
+    List<vlc_ml_media_list_t, VLC_ML_LIST_SHOW_EPISODES, VLC_ML_COUNT_SHOW_EPISODES, Media>;
+
+using MediaFolderList = List<vlc_ml_media_list_t, VLC_ML_LIST_FOLDER_MEDIAS, VLC_ML_COUNT_FOLDER_MEDIAS, Media>;
+
+} // namespace ml
+
+#endif /* ML_HPP */
diff --git a/modules/control/upnp_server/share/ConnectionManager.xml b/modules/control/upnp_server/share/ConnectionManager.xml
new file mode 100644
index 0000000000..86ae41d89b
--- /dev/null
+++ b/modules/control/upnp_server/share/ConnectionManager.xml
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="utf-8"?>
+<scpd xmlns="urn:schemas-upnp-org:service-1-0">
+   <specVersion>
+      <major>1</major>
+      <minor>0</minor>
+   </specVersion>
+   <actionList>
+      <action>
+         <name>GetCurrentConnectionIDs</name>
+         <argumentList>
+            <argument>
+               <name>ConnectionIDs</name>
+               <direction>out</direction>
+               <relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+      <action>
+         <name>GetCurrentConnectionInfo</name>
+         <argumentList>
+            <argument>
+               <name>ConnectionID</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>RcsID</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>AVTransportID</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>ProtocolInfo</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>PeerConnectionManager</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>PeerConnectionID</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Direction</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Status</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+      <action>
+         <name>GetProtocolInfo</name>
+         <argumentList>
+            <argument>
+               <name>Source</name>
+               <direction>out</direction>
+               <relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Sink</name>
+               <direction>out</direction>
+               <relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+   </actionList>
+   <serviceStateTable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_ProtocolInfo</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_ConnectionStatus</name>
+         <dataType>string</dataType>
+         <allowedValueList>
+            <allowedValue>OK</allowedValue>
+            <allowedValue>ContentFormatMismatch</allowedValue>
+            <allowedValue>InsufficientBandwidth</allowedValue>
+            <allowedValue>UnreliableChannel</allowedValue>
+            <allowedValue>Unknown</allowedValue>
+         </allowedValueList>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_AVTransportID</name>
+         <dataType>i4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_RcsID</name>
+         <dataType>i4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_ConnectionID</name>
+         <dataType>i4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_ConnectionManager</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="yes">
+         <name>SourceProtocolInfo</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="yes">
+         <name>SinkProtocolInfo</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_Direction</name>
+         <dataType>string</dataType>
+         <allowedValueList>
+            <allowedValue>Input</allowedValue>
+            <allowedValue>Output</allowedValue>
+         </allowedValueList>
+      </stateVariable>
+      <stateVariable sendEvents="yes">
+         <name>CurrentConnectionIDs</name>
+         <dataType>string</dataType>
+      </stateVariable>
+   </serviceStateTable>
+</scpd>
diff --git a/modules/control/upnp_server/share/ContentDirectory.xml b/modules/control/upnp_server/share/ContentDirectory.xml
new file mode 100644
index 0000000000..3149ba1f08
--- /dev/null
+++ b/modules/control/upnp_server/share/ContentDirectory.xml
@@ -0,0 +1,207 @@
+<?xml version="1.0" encoding="utf-8"?>
+<scpd xmlns="urn:schemas-upnp-org:service-1-0">
+   <specVersion>
+      <major>1</major>
+      <minor>0</minor>
+   </specVersion>
+   <actionList>
+      <action>
+         <name>Browse</name>
+         <argumentList>
+            <argument>
+               <name>ObjectID</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>BrowseFlag</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Filter</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>StartingIndex</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>RequestedCount</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>SortCriteria</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Result</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>NumberReturned</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>TotalMatches</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>UpdateID</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+      <action>
+         <name>Search</name>
+         <argumentList>
+            <argument>
+               <name>ContainerID</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>SearchCriteria</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_SearchCriteria</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Filter</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>StartingIndex</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>RequestedCount</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>SortCriteria</name>
+               <direction>in</direction>
+               <relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>Result</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>NumberReturned</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>TotalMatches</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
+            </argument>
+            <argument>
+               <name>UpdateID</name>
+               <direction>out</direction>
+               <relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+      <action>
+         <name>GetSearchCapabilities</name>
+         <argumentList>
+            <argument>
+               <name>SearchCaps</name>
+               <direction>out</direction>
+               <relatedStateVariable>SearchCapabilities</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+      <action>
+         <name>GetSortCapabilities</name>
+         <argumentList>
+            <argument>
+               <name>SortCaps</name>
+               <direction>out</direction>
+               <relatedStateVariable>SortCapabilities</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+      <action>
+         <name>GetSystemUpdateID</name>
+         <argumentList>
+            <argument>
+               <name>Id</name>
+               <direction>out</direction>
+               <relatedStateVariable>SystemUpdateID</relatedStateVariable>
+            </argument>
+         </argumentList>
+      </action>
+   </actionList>
+   <serviceStateTable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_BrowseFlag</name>
+         <dataType>string</dataType>
+         <allowedValueList>
+            <allowedValue>BrowseMetadata</allowedValue>
+            <allowedValue>BrowseDirectChildren</allowedValue>
+         </allowedValueList>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_SearchCriteria</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>SystemUpdateID</name>
+         <dataType>ui4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>ContainerUpdateIDs</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_Count</name>
+         <dataType>ui4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_SortCriteria</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>SortCapabilities</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_Index</name>
+         <dataType>ui4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_ObjectID</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_UpdateID</name>
+         <dataType>ui4</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_Result</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>SearchCapabilities</name>
+         <dataType>string</dataType>
+      </stateVariable>
+      <stateVariable sendEvents="no">
+         <name>A_ARG_TYPE_Filter</name>
+         <dataType>string</dataType>
+      </stateVariable>
+   </serviceStateTable>
+</scpd>
diff --git a/modules/control/upnp_server/share/X_MS_MediaReceiverRegistrar.xml b/modules/control/upnp_server/share/X_MS_MediaReceiverRegistrar.xml
new file mode 100644
index 0000000000..3014725ed8
--- /dev/null
+++ b/modules/control/upnp_server/share/X_MS_MediaReceiverRegistrar.xml
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="utf-8"?>
+<scpd xmlns="urn:schemas-upnp-org:service-1-0">
+<specVersion>
+<major>1</major>
+<minor>0</minor>
+</specVersion>
+<actionList>
+<action>
+<name>IsAuthorized</name>
+<argumentList>
+<argument>
+<name>DeviceID</name>
+<direction>in</direction>
+<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>
+</argument>
+<argument>
+<name>Result</name>
+<direction>out</direction>
+<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
+</argument>
+</argumentList>
+</action>
+<action>
+<name>RegisterDevice</name>
+<argumentList>
+<argument>
+<name>RegistrationReqMsg</name>
+<direction>in</direction>
+<relatedStateVariable>A_ARG_TYPE_RegistrationReqMsg</relatedStateVariable>
+</argument>
+<argument>
+<name>RegistrationRespMsg</name>
+<direction>out</direction>
+<relatedStateVariable>A_ARG_TYPE_RegistrationRespMsg</relatedStateVariable>
+</argument>
+</argumentList>
+</action>
+<action>
+<name>IsValidated</name>
+<argumentList>
+<argument>
+<name>DeviceID</name>
+<direction>in</direction>
+<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>
+</argument>
+<argument>
+<name>Result</name>
+<direction>out</direction>
+<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
+</argument>
+</argumentList>
+</action>
+</actionList>
+<serviceStateTable>
+<stateVariable sendEvents="no">
+<name>A_ARG_TYPE_DeviceID</name>
+<dataType>string</dataType>
+</stateVariable>
+<stateVariable sendEvents="no">
+<name>A_ARG_TYPE_Result</name>
+<dataType>int</dataType>
+</stateVariable>
+<stateVariable sendEvents="no">
+<name>A_ARG_TYPE_RegistrationReqMsg</name>
+<dataType>bin.base64</dataType>
+</stateVariable>
+<stateVariable sendEvents="no">
+<name>A_ARG_TYPE_RegistrationRespMsg</name>
+<dataType>bin.base64</dataType>
+</stateVariable>
+<stateVariable sendEvents="yes">
+<name>AuthorizationGrantedUpdateID</name>
+<dataType>ui4</dataType>
+</stateVariable>
+<stateVariable sendEvents="yes">
+<name>AuthorizationDeniedUpdateID</name>
+<dataType>ui4</dataType>
+</stateVariable>
+<stateVariable sendEvents="yes">
+<name>ValidationSucceededUpdateID</name>
+<dataType>ui4</dataType>
+</stateVariable>
+<stateVariable sendEvents="yes">
+<name>ValidationRevokedUpdateID</name>
+<dataType>ui4</dataType>
+</stateVariable>
+</serviceStateTable>
+</scpd>
diff --git a/modules/control/upnp_server/sout.cpp b/modules/control/upnp_server/sout.cpp
new file mode 100644
index 0000000000..48b30732d6
--- /dev/null
+++ b/modules/control/upnp_server/sout.cpp
@@ -0,0 +1,220 @@
+/*****************************************************************************
+ * sout.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "upnp_server.hpp"
+
+#include <vlc_sout.h>
+#include <vlc_stream.h>
+#include <vlc_stream_extractor.h>
+
+#include <iostream>
+#include <atomic>
+
+const char TranscodeFifo::VAR_NAME[] = SERVER_PREFIX "fifo";
+
+
+size_t TranscodeFifo::read( uint8_t buf[], size_t buflen ) noexcept
+{
+    vlc_fifo_Lock( queue.get() );
+
+    size_t bytes;
+    while ( ( bytes = vlc_fifo_GetBytes( queue.get() ) ) < buflen / 2 && !eof )
+    {
+        vlc_fifo_Wait( queue.get() );
+    }
+    buflen = std::min(buflen, bytes);
+
+    size_t bytes_read = 0;
+    while ( bytes_read < buflen )
+    {
+        block_t* block = vlc_fifo_DequeueUnlocked( queue.get() );
+        assert(block);
+
+        const size_t to_copy = std::min( block->i_buffer, buflen - bytes_read );
+        memcpy( buf + bytes_read, block->p_buffer, to_copy );
+        bytes_read += to_copy;
+
+        block->p_buffer += to_copy;
+        block->i_buffer -= to_copy;
+
+        if ( block->i_buffer == 0 )
+            block_Release( block );
+        else
+        {
+            block_t* all = vlc_fifo_DequeueAllUnlocked( queue.get() );
+            block->p_next = all;
+            vlc_fifo_QueueUnlocked( queue.get(), block );
+            assert(bytes_read == buflen);
+        }
+    }
+
+    vlc_fifo_Signal( queue.get() );
+    vlc_fifo_Unlock( queue.get() );
+
+    return bytes_read;
+}
+
+namespace Server
+{
+namespace AccessOut
+{
+int open( vlc_object_t* p_this )
+{
+    sout_access_out_t* p_access = reinterpret_cast<sout_access_out_t*>( p_this );
+
+    p_access->p_sys = var_InheritAddress( p_this, TranscodeFifo::VAR_NAME );
+
+    p_access->pf_write = []( sout_access_out_t* access, block_t* data ) -> ssize_t {
+        if ( access->p_sys == nullptr )
+        {
+            return 0;
+        }
+        auto fifo = *static_cast<std::shared_ptr<TranscodeFifo>*>( access->p_sys );
+
+        size_t size = 0;
+        block_ChainProperties( data, nullptr, &size, nullptr );
+
+        vlc_fifo_Lock( fifo->queue.get() );
+
+        // The fifo holds too much data, wait for the http thread to empty it.
+        static constexpr auto FIFO_MAX_SIZE = INT64_C(2 * 1024 * 1024); /* 2 MB */
+        while(vlc_fifo_GetBytes(fifo->queue.get()) >= FIFO_MAX_SIZE) {
+            msg_Warn( access, "Fifo buffer full, pacing the transcode es out.");
+            vlc_fifo_Wait( fifo->queue.get() );
+        }
+
+        vlc_fifo_QueueUnlocked( fifo->queue.get(), data );
+        vlc_fifo_Unlock( fifo->queue.get() );
+
+        // No needs to signal the fifo cond_var as vlc_fifo_QueueUnlocked
+        // already does it
+
+        return size;
+    };
+
+    p_access->pf_control = []( sout_access_out_t*, int i_query, va_list args ) -> int {
+        switch ( i_query )
+        {
+        case ACCESS_OUT_CONTROLS_PACE:
+        {
+            bool* pb = va_arg( args, bool* );
+            *pb = false;
+            break;
+        }
+
+        case ACCESS_OUT_CAN_SEEK:
+        {
+            bool* pb = va_arg( args, bool* );
+            *pb = false;
+            break;
+        }
+
+        default:
+            return VLC_EGENERIC;
+        }
+        return VLC_SUCCESS;
+    };
+
+    p_access->pf_seek = nullptr;
+    p_access->pf_read = nullptr;
+
+    return VLC_SUCCESS;
+}
+
+void close( vlc_object_t* p_this )
+{
+    msg_Dbg( p_this, "Closing fifo" );
+
+    auto* fifo =
+        static_cast<std::shared_ptr<TranscodeFifo>*>( var_InheritAddress( p_this, TranscodeFifo::VAR_NAME ) );
+    assert( fifo );
+
+    auto* queue = ( *fifo )->queue.get();
+    vlc_fifo_Lock( queue );
+    ( *fifo )->eof = true;
+    vlc_fifo_Unlock( queue );
+
+    vlc_fifo_Signal( queue );
+
+    reinterpret_cast<sout_access_out_t*>( p_this )->p_sys = nullptr;
+
+    msg_Dbg( p_this, "Fifo closed" );
+}
+} // namespace AccessOut
+
+namespace StreamOutProxy
+{
+
+struct sout_stream_sys_t
+{
+  std::atomic_bool is_flushed = ATOMIC_VAR_INIT(false);
+};
+
+static void* add( sout_stream_t* s, const es_format_t* fmt )
+{
+    return sout_StreamIdAdd( s->p_next, fmt );
+}
+
+static void del( sout_stream_t* s, void* id )
+{
+    return sout_StreamIdDel( s->p_next, id );
+}
+
+static int send( sout_stream_t* p_stream, void* id, block_t* p_buffer )
+{
+    const sout_stream_sys_t* p_sys = reinterpret_cast<sout_stream_sys_t*>( p_stream->p_sys );
+    if ( !p_sys->is_flushed )
+    {
+        block_ChainRelease( p_buffer );
+        return VLC_SUCCESS;
+    }
+    return sout_StreamIdSend( p_stream->p_next, id, p_buffer );
+}
+
+
+static void flush(sout_stream_t *p_stream, void* id)
+{
+  sout_stream_sys_t* p_sys = reinterpret_cast<sout_stream_sys_t*>( p_stream->p_sys );
+  p_sys->is_flushed.store(true);
+  sout_StreamFlush(p_stream->p_next, id);
+}
+
+static const sout_stream_operations operations = { add, del, send, nullptr, flush };
+
+int open( vlc_object_t* p_this )
+{
+    sout_stream_t* p_stream = reinterpret_cast<sout_stream_t*>( p_this );
+
+    auto* sys = new sout_stream_sys_t;
+
+    p_stream->p_sys = sys;
+    p_stream->ops = &operations;
+    return VLC_SUCCESS;
+}
+
+void close( vlc_object_t* p_this )
+{
+    sout_stream_t* p_stream = reinterpret_cast<sout_stream_t*>( p_this );
+
+    delete static_cast<sout_stream_sys_t*>( p_stream->p_sys );
+}
+} // namespace StreamOutProxy
+} // namespace Server
diff --git a/modules/control/upnp_server/test/cds.cpp b/modules/control/upnp_server/test/cds.cpp
new file mode 100644
index 0000000000..9657274fbc
--- /dev/null
+++ b/modules/control/upnp_server/test/cds.cpp
@@ -0,0 +1,113 @@
+/*****************************************************************************
+ * test/cds.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "cxx_test_helper.hpp"
+
+#include "../cds/cds.hpp"
+
+
+TEST(ParseIdSimple) {
+  using namespace cds;
+
+  unsigned id;
+  Object::ExtraId extra;
+
+  std::tie(id, extra) = parse_id("0");
+  ASSERT_EQ(id, 0);
+  ASSERT_FALSE(extra.has_value());
+
+  std::tie(id, extra) = parse_id("42");
+  ASSERT_EQ(id, 42);
+  ASSERT_FALSE(extra.has_value());
+
+  std::tie(id, extra) = parse_id("33:-1");
+  ASSERT_EQ(id, 33);
+  ASSERT_TRUE(extra.has_value());
+  ASSERT_EQ(extra.value().ml_id, -1);
+
+  std::tie(id, extra) = parse_id("0:2222");
+  ASSERT_EQ(id, 0);
+  ASSERT_TRUE(extra.has_value());
+  ASSERT_EQ(extra.value().ml_id, 2222);
+
+}
+
+TEST(ParseIdParent) {
+  using namespace cds;
+
+  unsigned id;
+  Object::ExtraId extra;
+
+  std::tie(id, extra) = parse_id("0:3(-1)");
+  ASSERT_EQ(id, 0);
+  ASSERT_TRUE(extra.has_value());
+  ASSERT_EQ(extra.value().ml_id, 3);
+  ASSERT_EQ(extra.value().parent, "-1");
+
+  std::tie(id, extra) = parse_id("1:3(24:2)");
+  ASSERT_EQ(id, 1);
+  ASSERT_TRUE(extra.has_value());
+  ASSERT_EQ(extra.value().ml_id, 3);
+  ASSERT_EQ(extra.value().parent, "24:2");
+
+  std::tie(id, extra) = parse_id("3:88(0:4(2:34))");
+  ASSERT_EQ(id, 3);
+  ASSERT_TRUE(extra.has_value());
+  ASSERT_EQ(extra.value().ml_id, 88);
+  ASSERT_EQ(extra.value().parent, "0:4(2:34)");
+
+  std::tie(id, extra) = parse_id("3:88(0:4(2:34(0:0)))");
+  ASSERT_EQ(id, 3);
+  ASSERT_TRUE(extra.has_value());
+  ASSERT_EQ(extra.value().ml_id, 88);
+  ASSERT_EQ(extra.value().parent, "0:4(2:34(0:0))");
+}
+
+TEST(ParseIdErrorCases) {
+  using namespace cds;
+
+  unsigned id;
+  Object::ExtraId extra;
+  ASSERT_THROW(parse_id(""), std::invalid_argument);
+  ASSERT_THROW(parse_id(":"), std::invalid_argument);
+  ASSERT_THROW(parse_id("text"), std::invalid_argument);
+
+  std::tie(id, extra) = parse_id("0:");
+  ASSERT_EQ(id, 0);
+  ASSERT_FALSE(extra.has_value());
+
+  std::tie(id, extra) = parse_id("23:(");
+  ASSERT_EQ(id, 23);
+  ASSERT_FALSE(extra.has_value());
+
+  std::tie(id, extra) = parse_id("8:()");
+  ASSERT_EQ(id, 8);
+  ASSERT_FALSE(extra.has_value());
+
+  std::tie(id, extra) = parse_id("22:whatever");
+  ASSERT_EQ(id, 22);
+  ASSERT_FALSE(extra.has_value());
+
+  std::tie(id, extra) = parse_id("2:(1)");
+  ASSERT_EQ(id, 2);
+  ASSERT_FALSE(extra.has_value());
+
+}
diff --git a/modules/control/upnp_server/test/cxx_test_helper.hpp b/modules/control/upnp_server/test/cxx_test_helper.hpp
new file mode 100644
index 0000000000..667dcf11aa
--- /dev/null
+++ b/modules/control/upnp_server/test/cxx_test_helper.hpp
@@ -0,0 +1,257 @@
+/*****************************************************************************
+ * cxx_test_helper.hpp : Small unit test helper
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef CXX_TEST_HELPER_HPP
+#define CXX_TEST_HELPER_HPP
+
+#include <functional>
+#include <iostream>
+#include <sstream>
+#include <type_traits>
+#include <vector>
+
+namespace test
+{
+
+struct AssertFailure : std::exception
+{
+    template <typename T>
+    AssertFailure( const std::pair<std::string, int>& location, const char* prelude, T&& value )
+    {
+        std::stringstream ss;
+        ss << location.first << ":" << location.second << " Assertion failed" << std::endl;
+        ss << prelude << ", actual: " << std::forward<T>( value );
+        err = ss.str();
+    }
+
+    template <typename Lhs, typename Rhs>
+    AssertFailure( const std::pair<std::string, int>& location,
+                   const char* prelude,
+                   Lhs&& lhs,
+                   Rhs&& rhs )
+    {
+        std::stringstream ss( prelude );
+        ss << location.first << ":" << location.second << " Assertion failed" << std::endl;
+        ss << prelude << ", actual: " << std::forward<Lhs>( lhs ) << " vs "
+           << std::forward<Rhs>( rhs );
+        err = ss.str();
+    }
+
+    AssertFailure( const std::pair<std::string, int>& location, const char* error )
+    {
+        std::stringstream ss;
+        ss << location.first << ":" << location.second << " Assertion failed" << std::endl;
+        ss << error;
+        err = ss.str();
+    }
+
+    const char* what() const noexcept override { return err.c_str(); }
+
+    private:
+    std::string err;
+};
+
+struct Logger
+{
+
+    enum class State
+    {
+        Pass,
+        Fail
+    };
+
+    bool verbose = true;
+
+    void pass( const char test_name[] ) const
+    {
+        if ( verbose )
+        {
+            std::cout << "[PASS] " << test_name << std::endl;
+        }
+    }
+
+    void fail( const char test_name[], const char* error ) const
+    {
+        std::cerr << "[FAIL] " << test_name << std::endl << error << std::endl;
+    }
+};
+
+using TestFn = std::function<int( const Logger& )>;
+struct TestPool
+{
+    template <typename Test>
+    struct Register
+    {
+        explicit Register()
+        {
+            static_test_pool().emplace_back( []( const Logger& log ) {
+                try
+                {
+                    Test().run();
+                }
+                catch ( const test::AssertFailure& e )
+                {
+                    log.fail( Test::NAME, e.what() );
+                    return false;
+                }
+                catch ( ... )
+                {
+                    log.fail( Test::NAME, "Unexpected exception, rethrowing it..." );
+                    std::rethrow_exception( std::current_exception() );
+                }
+                log.pass( Test::NAME );
+                return true;
+            } );
+        }
+    };
+
+    static bool run_all( const Logger& logger )
+    {
+        bool success = true;
+        for ( const auto& test : static_test_pool() )
+        {
+            success &= test( logger );
+        }
+        return success;
+    }
+
+    private:
+    static std::vector<TestFn>& static_test_pool()
+    {
+        static std::vector<TestFn> test_pool;
+        return test_pool;
+    }
+};
+
+#define BUILD_ASSERT_FAILURE( ... ) test::AssertFailure( { __FILE__, __LINE__ }, __VA_ARGS__ )
+
+#define ASSERT_TRUE( cond )                                                                        \
+    if ( !( cond ) )                                                                               \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #cond ") to be true", "false" );
+#define ASSERT_FALSE( cond )                                                                       \
+    if ( ( cond ) )                                                                                \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #cond ") to be false", "true" );
+
+#define ASSERT_EQ( lhs, rhs )                                                                      \
+    if ( ( lhs ) != ( rhs ) )                                                                      \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #lhs ") == (" #rhs ")", ( lhs ), ( rhs ) );
+#define ASSERT_NE( lhs, rhs )                                                                      \
+    if ( ( lhs ) == ( rhs ) )                                                                      \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #lhs ") != (" #rhs ")", ( lhs ), ( rhs ) );
+#define ASSERT_LT( lhs, rhs )                                                                      \
+    if ( ( lhs ) <= ( rhs ) )                                                                      \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #lhs ") < (" #rhs ")", ( lhs ), ( rhs ) );
+#define ASSERT_LE( lhs, rhs )                                                                      \
+    if ( ( lhs ) > ( rhs ) )                                                                       \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #lhs ") <= (" #rhs ")", ( lhs ), ( rhs ) );
+#define ASSERT_GT( lhs, rhs )                                                                      \
+    if ( ( lhs ) <= ( rhs ) )                                                                      \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #lhs ") > (" #rhs ")", ( lhs ), ( rhs ) );
+#define ASSERT_GE( lhs, rhs )                                                                      \
+    if ( ( lhs ) < ( rhs ) )                                                                       \
+        throw BUILD_ASSERT_FAILURE( "Expected (" #lhs ") >= (" #rhs ")", ( lhs ), ( rhs ) );
+
+#define ASSERT_NEAR( lhs, rhs, epsilon )                                                           \
+    if ( std::abs( ( lhs ) - ( rhs ) ) > ( epsilon ) )                                             \
+        throw BUILD_ASSERT_FAILURE( "Expected abs((" #lhs ") - (" #rhs ")) <= (" #epsilon ")",     \
+                                    ( lhs ), ( rhs ) );
+
+#define ASSERT_THROW( statement, exception_type )                                                  \
+    {                                                                                              \
+        bool throwed = false;                                                                      \
+        try                                                                                        \
+        {                                                                                          \
+            ( statement );                                                                         \
+        }                                                                                          \
+        catch ( const exception_type& )                                                            \
+        {                                                                                          \
+            throwed = true;                                                                        \
+        }                                                                                          \
+        catch ( ... )                                                                              \
+        {                                                                                          \
+            throw BUILD_ASSERT_FAILURE( "Expected (" #exception_type                               \
+                                        ") to be thrown but caught another" );                     \
+        }                                                                                          \
+        if ( !throwed )                                                                            \
+            throw BUILD_ASSERT_FAILURE( "Did not throw" );                                         \
+    }
+#define ASSERT_ANY_THROW( statement )                                                              \
+    {                                                                                              \
+        bool throwed = false;                                                                      \
+        try                                                                                        \
+        {                                                                                          \
+            ( statement );                                                                         \
+        }                                                                                          \
+        catch ( ... )                                                                              \
+        {                                                                                          \
+            throwed = true;                                                                        \
+        }                                                                                          \
+        if ( !throwed )                                                                            \
+            throw BUILD_ASSERT_FAILURE( "Did not throw" );                                         \
+    }
+#define ASSERT_NO_THROW( statement )                                                               \
+    try                                                                                            \
+    {                                                                                              \
+        ( statement );                                                                             \
+    }                                                                                              \
+    catch ( ... )                                                                                  \
+    {                                                                                              \
+        throw BUILD_ASSERT_FAILURE( "Unexpected exception caught" );                               \
+    }
+
+#define TEST( name )                                                                               \
+    struct test_##name                                                                             \
+    {                                                                                              \
+        static constexpr auto NAME = #name;                                                        \
+        void run();                                                                                \
+    };                                                                                             \
+    test::TestPool::Register<test_##name> test_impl_##name;                                        \
+    void test_##name::run()
+
+struct Test
+{
+    virtual ~Test() = default;
+
+    virtual void init(){};
+    virtual void release(){};
+};
+
+#define TEST_F( Fixture, name )                                                                    \
+    static_assert( std::is_base_of<test::Test, Fixture>::value,                                    \
+                   #Fixture " should derive test::Test" );                                         \
+    struct test_##name : ( Fixture )                                                               \
+    {                                                                                              \
+        static constexpr auto NAME = #name;                                                        \
+        void do_run();                                                                             \
+        void run()                                                                                 \
+        {                                                                                          \
+            init();                                                                                \
+            do_run();                                                                              \
+            release();                                                                             \
+        }                                                                                          \
+    };                                                                                             \
+    test::TestPool::Register<test_##name> test_impl_##name;                                        \
+    void test_##name::do_run()
+
+} // namespace test
+
+#endif /* CXX_TEST_HELPER_HPP */
diff --git a/modules/control/upnp_server/test/main.cpp b/modules/control/upnp_server/test/main.cpp
new file mode 100644
index 0000000000..76902703f3
--- /dev/null
+++ b/modules/control/upnp_server/test/main.cpp
@@ -0,0 +1,26 @@
+/*****************************************************************************
+ * test/main.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "cxx_test_helper.hpp"
+
+#include <cstdlib>
+
+int main() { return test::TestPool::run_all( test::Logger() ) ? EXIT_SUCCESS : EXIT_FAILURE; }
diff --git a/modules/control/upnp_server/test/utils.cpp b/modules/control/upnp_server/test/utils.cpp
new file mode 100644
index 0000000000..b987da05e8
--- /dev/null
+++ b/modules/control/upnp_server/test/utils.cpp
@@ -0,0 +1,71 @@
+/*****************************************************************************
+ * test/utils.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "./cxx_test_helper.hpp"
+
+#include "../utils.hpp"
+
+#include <chrono>
+
+TEST( ParseDlnaNpt )
+{
+    using namespace utils;
+    using namespace std::chrono;
+
+    const auto to_ms = []( auto s ) { return duration_cast<milliseconds>( s ).count(); };
+
+    // Invalid cases
+    ASSERT_EQ( utils::parse_dlna_npt_range( "" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=-5" ), 0 );
+
+    ASSERT_EQ( utils::parse_dlna_npt_range( "32:a:2-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "abcd" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "abc=4" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "55" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=-2" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=42" ), 0 );
+
+    // Time specified in seconds
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=0-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=42-" ), to_ms( 42s ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=55.32-" ), to_ms( 55s + 320ms ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=23.4-" ), to_ms( 23s + 400ms ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=443-" ), to_ms( 443s ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=175.2346-" ), to_ms( 175s + 234ms ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=534.250-" ), to_ms( 534s + 250ms ) );
+
+    // Invalid date time
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=a:05:23-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=1:w:23-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=1:05:b-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=1:05:22" ), 0 );
+
+    // Time specified by date
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=00:00:00-" ), 0 );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=12:05:23-" ), to_ms( 12h + 5min + 23s ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=1:00:00.5-" ), to_ms( 1h + 500ms ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=00:1:00-" ), to_ms( 1min ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=00:00:1-" ), to_ms( 1s ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=1:2:3-" ), to_ms( 1h + 2min + 3s ) );
+    ASSERT_EQ( utils::parse_dlna_npt_range( "npt=25:1:2-" ), to_ms( 25h + 1min + 2s ) );
+}
diff --git a/modules/control/upnp_server/upnp_server.cpp b/modules/control/upnp_server/upnp_server.cpp
new file mode 100644
index 0000000000..fa9210031d
--- /dev/null
+++ b/modules/control/upnp_server/upnp_server.cpp
@@ -0,0 +1,546 @@
+/*****************************************************************************
+ * upnp_server.cpp : UPnP server module
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Hamza Parnica <hparnica at gmail.com>
+ *          Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+
+#include "upnp_server.hpp"
+
+
+#include <vlc_common.h>
+
+#include <vlc_addons.h>
+#include <vlc_cxx_helpers.hpp>
+#include <vlc_interface.h>
+#include <vlc_player.h>
+#include <vlc_rand.h>
+#include <vlc_stream_extractor.h>
+#include <vlc_url.h>
+
+#include <atomic>
+#include <cstring>
+#include <iostream>
+
+#include "FileHandler.hpp"
+#include "cds/cds.hpp"
+#include "cds/Container.hpp"
+#include "utils.hpp"
+
+#define CDS_ID "urn:upnp-org:serviceId:ContentDirectory"
+#define CMS_ID "urn:upnp-org:serviceId:ConnectionManager"
+
+#define UPNP_SERVICE_TYPE(service) "urn:schemas-upnp-org:service:" service ":1"
+
+static void medialibrary_event_callback( void* p_data, const struct vlc_ml_event_t* p_event )
+{
+    intf_thread_t* p_intf = (intf_thread_t*)p_data;
+    intf_sys_t* p_sys = p_intf->p_sys;
+
+    switch ( p_event->i_type )
+    {
+    case VLC_ML_EVENT_MEDIA_ADDED:
+    case VLC_ML_EVENT_MEDIA_UPDATED:
+    case VLC_ML_EVENT_MEDIA_DELETED:
+    case VLC_ML_EVENT_ARTIST_ADDED:
+    case VLC_ML_EVENT_ARTIST_UPDATED:
+    case VLC_ML_EVENT_ARTIST_DELETED:
+    case VLC_ML_EVENT_ALBUM_ADDED:
+    case VLC_ML_EVENT_ALBUM_UPDATED:
+    case VLC_ML_EVENT_ALBUM_DELETED:
+    case VLC_ML_EVENT_PLAYLIST_ADDED:
+    case VLC_ML_EVENT_PLAYLIST_UPDATED:
+    case VLC_ML_EVENT_PLAYLIST_DELETED:
+    case VLC_ML_EVENT_GENRE_ADDED:
+    case VLC_ML_EVENT_GENRE_UPDATED:
+    case VLC_ML_EVENT_GENRE_DELETED:
+        p_sys->upnp_update_id++;
+        break;
+    }
+}
+
+static bool cds_browse( UpnpActionRequest* p_request, intf_thread_t* p_intf )
+{
+    intf_sys_t* p_sys = p_intf->p_sys;
+
+    const auto* action_rq = UpnpActionRequest_get_ActionRequest( p_request );
+
+    const char* psz_object_id = xml_getChildElementValue( (IXML_Element*)action_rq, "ObjectID" );
+    if ( psz_object_id == nullptr )
+        return false;
+
+    const char* psz_browse_flag =
+        xml_getChildElementValue( (IXML_Element*)action_rq, "BrowseFlag" );
+    const uint32_t u_starting_index =
+        strtoull( xml_getChildElementValue( (IXML_Element*)action_rq, "StartingIndex" ), NULL, 10 );
+    const uint32_t u_requested_count =
+        strtoul( xml_getChildElementValue( (IXML_Element*)action_rq, "RequestedCount" ), NULL, 10 );
+
+    xml::Document result;
+    xml::Element didl_lite = result.create_element( "DIDL-Lite" );
+
+    // Standard upnp attributes
+    didl_lite.set_attribute( "xmlns", "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" );
+    didl_lite.set_attribute( "xmlns:dc", "http://purl.org/dc/elements/1.1/" );
+    didl_lite.set_attribute( "xmlns:upnp", "urn:schemas-upnp-org:metadata-1-0/upnp/" );
+
+    const std::string id = psz_object_id;
+    unsigned obj_idx;
+    cds::Object::ExtraId extra_id;
+
+    try {
+      std::tie( obj_idx, extra_id ) = cds::parse_id( id );
+    } catch (const std::invalid_argument&e) {
+      UpnpActionRequest_set_ErrCode(p_request, 500);
+      return false;
+    }
+
+    auto* extra_hdrs = (UpnpListHead*)UpnpActionRequest_get_ExtraHeadersList( p_request );
+    const auto& client_profile =
+        p_sys->profile_from_headers( extra_hdrs, UpnpActionRequest_get_Os_cstr( p_request ) );
+
+    std::string str_nb_returned;
+    std::string str_total_matches;
+    const cds::Object& obj = *p_sys->obj_hierarchy[obj_idx];
+    if ( strcmp( psz_browse_flag, "BrowseDirectChildren" ) == 0 &&
+         obj.type == cds::Object::Type::Container )
+    {
+        const auto browse_stats = static_cast<const cds::Container&>( obj ).browse_direct_children(
+            didl_lite, { u_starting_index, u_requested_count }, extra_id, client_profile );
+        str_nb_returned = std::to_string( browse_stats.result_count );
+        str_total_matches = std::to_string( browse_stats.total_matches );
+    }
+    else if ( strcmp( psz_browse_flag, "BrowseMetadata" ) == 0 )
+    {
+        didl_lite.add_child( obj.browse_metadata( result, extra_id, client_profile ) );
+        str_nb_returned = "1";
+        str_total_matches = "1";
+    }
+
+    result.set_entry( std::move( didl_lite ) );
+
+    const char* action_name = UpnpActionRequest_get_ActionName_cstr( p_request );
+
+    const auto up_reponse_str = result.to_wrapped_cstr();
+    const auto str_update_id = std::to_string( p_sys->upnp_update_id.load() );
+
+    auto* p_answer = UpnpActionRequest_get_ActionResult( p_request );
+
+    static constexpr char service_type[] = UPNP_SERVICE_TYPE("ContentDirectory");
+    UpnpAddToActionResponse( &p_answer, action_name, service_type, "Result", up_reponse_str.get() );
+    UpnpAddToActionResponse( &p_answer, action_name, service_type, "NumberReturned", str_nb_returned.c_str() );
+    UpnpAddToActionResponse( &p_answer, action_name, service_type, "TotalMatches", str_total_matches.c_str() );
+    UpnpAddToActionResponse( &p_answer, action_name, service_type, "UpdateID", str_update_id.c_str() );
+
+    UpnpActionRequest_set_ActionResult( p_request, p_answer );
+    msg_Dbg( p_intf, "server: sending response to client: \n%s", up_reponse_str.get() );
+
+    return true;
+}
+
+static void handle_action_request( UpnpActionRequest* p_request, intf_thread_t* p_intf )
+{
+    intf_sys_t* p_sys = p_intf->p_sys;
+
+
+    IXML_Document* action_result = nullptr;
+    const char* service_id = UpnpActionRequest_get_ServiceID_cstr( p_request );
+    const char* action_name = UpnpActionRequest_get_ActionName_cstr( p_request );
+    const unsigned char* from =
+        (unsigned char*)&( (sockaddr_in*)UpnpActionRequest_get_CtrlPtIPAddr( p_request ) )->sin_addr.s_addr;
+    msg_Dbg( p_intf, "server: received action request \"%s\" for service \"%s\" from %u.%u.%u.%u", action_name,
+             service_id, from[0], from[1], from[2], from[3] );
+    if ( strcmp( service_id, CMS_ID ) == 0 )
+    {
+        static constexpr char cms_type[] = UPNP_SERVICE_TYPE( "ConnectionManager" );
+        if ( strcmp( action_name, "GetProtocolInfo" ) == 0 )
+        {
+            UpnpAddToActionResponse( &action_result, action_name, cms_type, "Source",
+                                     "http-get:*:*:*" );
+            UpnpAddToActionResponse( &action_result, action_name, cms_type, "Sink", "" );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+        else if ( strcmp( action_name, "GetCurrentConnectionIDs" ) == 0 )
+        {
+            UpnpAddToActionResponse( &action_result, action_name, cms_type, "ConnectionIDs", "" );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+    }
+
+    else if ( strcmp( service_id, CDS_ID ) == 0 )
+    {
+        static constexpr char cds_type[] = UPNP_SERVICE_TYPE( "ContentDirectory" );
+        if ( strcmp( action_name, "Browse" ) == 0 )
+        {
+            if ( !cds_browse( p_request, p_intf ) )
+                msg_Err( p_intf, "server: failed to respond to browse action request" );
+        }
+        else if ( strcmp( action_name, "GetSearchCapabilities" ) == 0 )
+        {
+            UpnpAddToActionResponse( &action_result, action_name, cds_type, "SearchCaps", "" );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+        else if ( strcmp( action_name, "GetSortCapabilities" ) == 0 )
+        {
+            UpnpAddToActionResponse( &action_result, action_name, cds_type, "SortCaps", "" );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+        else if ( strcmp( action_name, "GetSystemUpdateID" ) == 0 )
+        {
+            char* psz_update_id;
+            if ( asprintf( &psz_update_id, "%d", p_sys->upnp_update_id.load() ) == -1 )
+                return;
+            auto up_update_id = vlc::wrap_cptr( psz_update_id );
+            UpnpAddToActionResponse( &action_result, action_name, cds_type, "Id", psz_update_id );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+    }
+    else if ( strcmp( service_id, "urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar" ) == 0 ) {
+        if ( strcmp( action_name, "IsAuthorized" ) == 0 )
+        {
+            UpnpAddToActionResponse( &action_result, action_name, CDS_ID, "Result", "1" );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+        else if ( strcmp( action_name, "IsValidated" ) == 0 )
+        {
+            UpnpAddToActionResponse( &action_result, action_name, CDS_ID, "Result", "1" );
+            UpnpActionRequest_set_ActionResult( p_request, action_result );
+        }
+
+    }
+}
+
+static int Callback( Upnp_EventType event_type, const void* p_event, void* p_cookie )
+{
+    intf_thread_t* p_intf = (intf_thread_t*)p_cookie;
+
+    switch ( event_type )
+    {
+    case UPNP_CONTROL_ACTION_REQUEST:
+    {
+        msg_Warn( p_intf, "server: action request" );
+        // We need to const_cast here because the upnp callback has to take a const void* for the
+        // event data even if the sdk also expect us to modify it sometimes (in the case of a upnp
+        // response for example)
+        auto* rq =
+            const_cast<UpnpActionRequest*>( static_cast<const UpnpActionRequest*>( p_event ) );
+        handle_action_request( rq, p_intf );
+    }
+    break;
+    case UPNP_CONTROL_GET_VAR_REQUEST:
+        msg_Warn( p_intf, "server: var request" );
+        break;
+
+    case UPNP_EVENT_SUBSCRIPTION_REQUEST:
+        msg_Warn( p_intf, "server: sub request" );
+        break;
+
+    default:
+        msg_Err( p_intf, "server: unhandled event: %d", event_type );
+        return UPNP_E_INVALID_ACTION;
+    }
+
+    return UPNP_E_SUCCESS;
+}
+
+// UPNP Callbacks
+
+static int
+getinfo_cb( const char* url, UpnpFileInfo* info, intf_thread_t* intf, FileHandler** fhandler )
+{
+    const char* user_agent = UpnpFileInfo_get_Os_cstr(info);
+    if (user_agent == nullptr)
+      return UPNP_E_BAD_REQUEST;
+
+    msg_Dbg( intf, "GetInfo callback on: \"%s\" from: \"%s\"", url, user_agent );
+
+    UpnpFileInfo_set_IsReadable( info, true );
+    UpnpFileInfo_set_IsDirectory( info, false );
+
+    auto* header_list = (UpnpListHead*)UpnpFileInfo_get_ExtraHeadersList(info);
+    const auto & client_profile = intf->p_sys->profile_from_headers(header_list, user_agent);
+    auto file_handler = parse_url( url, client_profile, *intf->p_sys );
+
+    if ( file_handler == nullptr )
+        return UPNP_E_FILE_NOT_FOUND;
+
+    file_handler->get_info( *info );
+
+    // Pass the filehandler ownership to the open callback to avoid reparsing the url
+    *fhandler = file_handler.release();
+
+    return UPNP_E_SUCCESS;
+}
+
+static UpnpWebFileHandle
+open_cb( const char* url, enum UpnpOpenFileMode, intf_thread_t* intf, FileHandler* file_handler )
+{
+    msg_Dbg( intf, "Opening: %s", url );
+
+    FileHandler* ret = file_handler;
+    if ( !ret )
+        ret = parse_url( url, intf->p_sys->default_profile(), *intf->p_sys ).release();
+
+    if ( ret == nullptr )
+        return nullptr;
+
+    if ( !ret->open( intf ) )
+    {
+        msg_Err( intf, "Failed to open %s", url );
+        delete ret;
+        return nullptr;
+    }
+    return ret;
+}
+
+static int
+read_cb( UpnpWebFileHandle fileHnd, uint8_t buf[], size_t buflen, intf_thread_t* intf, const void* )
+{
+    assert( fileHnd );
+
+    const size_t bytes_read = static_cast<FileHandler*>( fileHnd )->read( buf, buflen );
+
+    msg_Dbg( intf, "http read callback, %zub requested %zub returned", buflen, bytes_read );
+
+    return bytes_read;
+}
+
+static int
+seek_cb( UpnpWebFileHandle fileHnd, off_t offset, int origin, intf_thread_t* intf, const void* )
+{
+    assert( fileHnd );
+    msg_Dbg( intf, "http seek callback offset: %ld origin: %d", offset, origin );
+
+    const bool success = static_cast<FileHandler*>( fileHnd )->seek(
+        static_cast<FileHandler::SeekType>( origin ), offset );
+    return success == true ? 0 : -1;
+}
+
+static int close_cb( UpnpWebFileHandle fileHnd, intf_thread_t* intf, const void* )
+{
+    assert( fileHnd );
+    msg_Dbg( intf, "http close callback" );
+    delete static_cast<FileHandler*>( fileHnd );
+    return 0;
+}
+
+static xml::Document make_server_identity(const char *uuid, const char *server_name) {
+  xml::Document ret;
+
+  const auto icon_elem = [&ret]( const char* url, const char* width,
+                                 const char* height ) -> xml::Element {
+      return ret.create_element( "icon",
+          ret.create_element( "mimetype", ret.create_text_node( "image/png" ) ),
+          ret.create_element( "width", ret.create_text_node( width ) ),
+          ret.create_element( "height", ret.create_text_node( height ) ),
+          ret.create_element( "depth", ret.create_text_node( "8" ) ),
+          ret.create_element( "url", ret.create_text_node( url ) ) );
+  };
+
+  const auto service_elem = [&ret]( const char* service ) -> xml::Element {
+    const auto type = std::string("urn:schemas-upnp-org:service:") + service + ":1";
+    const auto id = std::string("urn:upnp-org:serviceId:") + service;
+    const auto scpd_url = std::string("/") + service + ".xml";
+    const auto control_url = std::string("/") + service + "/Control";
+    const auto event_url = std::string("/") + service + "/Event";
+      return ret.create_element( "service",
+          ret.create_element( "serviceType", ret.create_text_node( type.c_str() ) ),
+          ret.create_element( "serviceId", ret.create_text_node( id.c_str() ) ),
+          ret.create_element( "SCPDURL", ret.create_text_node( scpd_url.c_str() ) ),
+          ret.create_element( "controlURL", ret.create_text_node( control_url.c_str() ) ),
+          ret.create_element( "eventSubURL", ret.create_text_node( event_url.c_str() ) ) );
+  };
+
+  const std::string url = utils::get_server_url();
+
+  const auto uuid_attr = std::string("uuid:") + uuid;
+
+  xml::Element dlna_doc = ret.create_element("dlna:X_DLNADOC", ret.create_text_node("DMS-1.50"));
+  dlna_doc.set_attribute("xmlns:dlna", "urn:schemas-dlna-org:device-1-0");
+
+  xml::Element root = ret.create_element("root",
+      ret.create_element("specVersion",
+        ret.create_element("major", ret.create_text_node( "1")),
+        ret.create_element("minor", ret.create_text_node( "0"))
+      ),
+      ret.create_element("device",
+        std::move(dlna_doc),
+
+        ret.create_element("deviceType", ret.create_text_node("urn:schemas-upnp-org:device:MediaServer:1")),
+        ret.create_element("presentationUrl", ret.create_text_node(url.c_str())),
+        ret.create_element("friendlyName", ret.create_text_node(server_name)),
+        ret.create_element("manufacturer", ret.create_text_node("VideoLAN")),
+        ret.create_element("manufacturerURL", ret.create_text_node("http://videolan.org")),
+        ret.create_element("modelDescription", ret.create_text_node("VLC Upnp Media Server")),
+        ret.create_element("modelName", ret.create_text_node("VLC")),
+        ret.create_element("modelNumber", ret.create_text_node( PACKAGE_VERSION )),
+        ret.create_element("modelURL", ret.create_text_node("http://videolan.org/vlc/")),
+        ret.create_element("serialNumber", ret.create_text_node("1")),
+        ret.create_element("UDN", ret.create_text_node(uuid_attr.c_str())),
+        ret.create_element("iconList",
+          icon_elem("/vlc.png", "32", "32"),
+          icon_elem("/vlc512x512.png", "512", "512")
+        ),
+        ret.create_element("serviceList",
+          service_elem("ConnectionManager"),
+          service_elem("ContentDirectory"),
+
+          ret.create_element( "service",
+            ret.create_element( "serviceType", ret.create_text_node( "urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1" ) ),
+            ret.create_element( "serviceId", ret.create_text_node( "urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar" ) ),
+            ret.create_element( "SCPDURL", ret.create_text_node( "/X_MS_MediaReceiverRegistrar.xml" ) ),
+            ret.create_element( "controlURL", ret.create_text_node( "/X_MS_MediaReceiverRegistrar/Control" ) ),
+            ret.create_element( "eventSubURL", ret.create_text_node( "/X_MS_MediaReceiverRegistrar/Event" ) )
+          )
+        )
+      )
+  );
+  root.set_attribute("xmlns", "urn:schemas-upnp-org:device-1-0");
+
+  ret.set_entry(std::move(root));
+  return ret;
+}
+
+static bool init_upnp( intf_thread_t* p_intf )
+{
+    intf_sys_t* p_sys = p_intf->p_sys;
+
+    int i_res;
+
+    addon_uuid_t uuid;
+    vlc_rand_bytes( uuid, sizeof( uuid ) );
+    p_sys->up_uuid = vlc::wrap_cptr(addons_uuid_to_psz( &uuid ), &free);
+
+    msg_Info( p_intf, "upnp server enabled on %s:%d", UpnpGetServerIpAddress(), UpnpGetServerPort() );
+
+    i_res = UpnpEnableWebserver( true );
+    if ( i_res != UPNP_E_SUCCESS )
+    {
+        msg_Err( p_intf, "server: enabling webserver failed: %s", UpnpGetErrorMessage( i_res ) );
+        return false;
+    }
+
+    const auto str_rootdir = utils::get_root_dir();
+
+    i_res = UpnpSetWebServerRootDir( str_rootdir.c_str() );
+    msg_Dbg(p_intf, "webserver root dir set to: \"%s\"", str_rootdir.c_str());
+    if ( i_res != UPNP_E_SUCCESS )
+    {
+        msg_Err( p_intf, "server: setting webserver root dir failed: %s",
+                 UpnpGetErrorMessage( i_res ) );
+        UpnpEnableWebserver( false );
+        return false;
+    }
+
+    const auto server_name =
+        vlc::wrap_cptr( var_InheritString( p_intf, SERVER_PREFIX "name" ), &free );
+    assert(server_name);
+    const auto presentation_doc = make_server_identity(p_sys->up_uuid.get(), server_name.get());
+    const auto up_presentation_str = presentation_doc.to_wrapped_cstr();
+    msg_Dbg(p_intf, "%s" , up_presentation_str.get());
+    i_res = UpnpRegisterRootDevice2( UPNPREG_BUF_DESC, up_presentation_str.get(),
+                                     strlen( up_presentation_str.get() ), 1, Callback, p_intf,
+                                     &p_sys->p_device_handle );
+    if ( i_res != UPNP_E_SUCCESS )
+    {
+        msg_Err( p_intf, "server: registration failed: %s", UpnpGetErrorMessage( i_res ) );
+        UpnpEnableWebserver( false );
+        return false;
+    }
+
+    UpnpAddVirtualDir( "/media", p_intf, nullptr );
+    UpnpAddVirtualDir( "/thumbnail", p_intf, nullptr );
+    UpnpAddVirtualDir( "/subtitle", p_intf, nullptr );
+    UpnpVirtualDir_set_GetInfoCallback( reinterpret_cast<VDCallback_GetInfo>( getinfo_cb ) );
+    UpnpVirtualDir_set_OpenCallback( reinterpret_cast<VDCallback_Open>( open_cb ) );
+    UpnpVirtualDir_set_ReadCallback( reinterpret_cast<VDCallback_Read>( read_cb ) );
+    UpnpVirtualDir_set_SeekCallback( reinterpret_cast<VDCallback_Seek>( seek_cb ) );
+    UpnpVirtualDir_set_CloseCallback( reinterpret_cast<VDCallback_Close>( close_cb ) );
+
+    i_res = UpnpSendAdvertisement( p_sys->p_device_handle, 1800 );
+    if ( i_res != UPNP_E_SUCCESS )
+    {
+        msg_Dbg( p_intf, "server: advertisement failed: %s", UpnpGetErrorMessage( i_res ) );
+        UpnpUnRegisterRootDevice( p_sys->p_device_handle );
+        UpnpEnableWebserver( false );
+        return false;
+    }
+
+    p_sys->upnp_update_id = 0;
+
+    return true;
+}
+
+namespace Server
+{
+int open( vlc_object_t* p_this )
+{
+    intf_thread_t* p_intf = (intf_thread_t*)p_this;
+
+    intf_sys_t* p_sys = new ( std::nothrow ) intf_sys_t;
+    if ( unlikely( p_sys == nullptr ) )
+        return VLC_ENOMEM;
+    p_intf->p_sys = p_sys;
+
+    p_sys->p_ml = vlc_ml_instance_get( p_this );
+    if ( !p_sys->p_ml )
+    {
+        msg_Err( p_intf, "server: cannot load server, medialibrary not initialized" );
+        delete p_sys;
+        return VLC_EGENERIC;
+    }
+    p_sys->p_ml_callback_handle =
+        vlc_ml_event_register_callback( p_sys->p_ml, medialibrary_event_callback, p_intf );
+    vlc_medialibrary_t* p_ml = p_sys->p_ml;
+    p_sys->up_ml_callback_handle =
+        std::unique_ptr<vlc_ml_event_callback_t, std::function<void( vlc_ml_event_callback_t* )>>{
+            p_sys->p_ml_callback_handle, [p_ml]( vlc_ml_event_callback_t* p_ch ) {
+                vlc_ml_event_unregister_callback( p_ml, p_ch );
+            } };
+
+    p_sys->up_upnp = vlc::wrap_cptr( UpnpInstanceWrapper::get( p_this ),
+                                     []( UpnpInstanceWrapper* p_upnp ) { p_upnp->release(); } );
+
+    p_sys->obj_hierarchy = cds::init_hierarchy(*p_ml);
+
+    if ( !init_upnp( p_intf ) )
+    {
+        delete p_sys;
+        return VLC_EGENERIC;
+    }
+
+    return VLC_SUCCESS;
+}
+
+void close( vlc_object_t* p_this )
+{
+    intf_thread_t* intf = (intf_thread_t*)p_this;
+    intf_sys_t* p_sys = intf->p_sys;
+
+    UpnpUnRegisterRootDevice( p_sys->p_device_handle );
+    UpnpEnableWebserver( false );
+
+    delete p_sys;
+}
+
+namespace StreamOut
+{
+
+} // namespace StreamOut
+} // namespace Server
diff --git a/modules/control/upnp_server/upnp_server.hpp b/modules/control/upnp_server/upnp_server.hpp
new file mode 100644
index 0000000000..9bbeaec377
--- /dev/null
+++ b/modules/control/upnp_server/upnp_server.hpp
@@ -0,0 +1,133 @@
+/*****************************************************************************
+ * upnp_server.hpp : UPnP server module header
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Hamza Parnica <hparnica at gmail.com>
+ *          Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+
+#pragma once
+#ifndef UPNP_SERVER_HPP
+#define UPNP_SERVER_HPP
+
+#ifdef HAVE_CONFIG_H
+#include "config.h"
+#endif
+
+#include <vlc_common.h>
+#include <vlc_media_library.h>
+
+#include "Profiles.hpp"
+#include "cds/cds.hpp"
+
+#include "../../services_discovery/upnp-wrapper.hpp"
+
+#include <vlc_block.h>
+
+#include <ExtraHeaders.h>
+
+#include <atomic>
+#include <functional>
+#include <memory>
+#include <vector>
+
+#define SERVER_PREFIX "upnp-server-"
+
+#define SERVER_DEFAULT_NAME N_("VLC Media Server")
+#define SERVER_NAME_DESC N_("Upnp server name")
+#define SERVER_NAME_LONGTEXT N_("The client exposed upnp server name")
+
+struct intf_sys_t
+{
+
+    vlc_medialibrary_t* p_ml;
+    vlc_ml_event_callback_t* p_ml_callback_handle;
+    std::unique_ptr<vlc_ml_event_callback_t, std::function<void( vlc_ml_event_callback_t* )>>
+        up_ml_callback_handle;
+
+    std::unique_ptr<char, decltype( &free )> up_uuid;
+
+    UpnpDevice_Handle p_device_handle;
+    std::unique_ptr<UpnpInstanceWrapper, std::function<void( UpnpInstanceWrapper* )>> up_upnp;
+
+    // This integer is atomically incremented at each medialib modification. It will be sent in each
+    // response. If the client notice that the update id has been incremented since the last
+    // request, he knows that the server state has changed and hence can refetch the exposed
+    // hierarchy accordingly.
+    std::atomic<unsigned int> upnp_update_id;
+
+    std::vector<std::unique_ptr<cds::Object>> obj_hierarchy;
+
+    intf_sys_t()
+        : up_ml_callback_handle( nullptr,
+                                 [&]( vlc_ml_event_callback_t* p_ch ) {
+                                     vlc_ml_event_unregister_callback( p_ml, p_ch );
+                                 } )
+        , up_uuid( nullptr, &free )
+        , up_upnp( nullptr, []( UpnpInstanceWrapper* p_upnp ) { p_upnp->release(); } )
+    {
+    }
+
+    // Client profiles describe the way medias should be exposed to certains clients.
+    // While most upnp/dlna clients are easy and flexible, some of them needs custom transcoding
+    // profiles due to their very strict implementation and/or poor codec coverage.
+    static const ClientProfile &profile_from_headers(UpnpListHead*, const char *user_agent) noexcept;
+    static const ClientProfile &default_profile() noexcept;
+};
+
+/// Transcoded media data blocks are held into that FIFO waiting the http upnp callbacks
+/// to send them. The AccessOut fill the fifo while the http servers empty it.
+struct TranscodeFifo
+{
+    static const char VAR_NAME[];
+    bool eof = false;
+
+    std::unique_ptr<block_fifo_t, decltype( &block_FifoRelease )> queue = { block_FifoNew(),
+                                                                            &block_FifoRelease };
+
+    size_t read( uint8_t buf[], size_t buflen ) noexcept;
+};
+
+namespace Server
+{
+int open( vlc_object_t* p_this );
+void close( vlc_object_t* p_this );
+
+/// We use this AccessOut to store the output of the upnp transcode pipeline into a block fifo.
+/// This module simply copy arriving blocks into a fifo and pace the muxer when the fifo is full.
+/// The fifo is emptied the libupnp http callbacks thread.
+namespace AccessOut
+{
+int open( vlc_object_t* );
+void close( vlc_object_t* );
+} // namespace AccessOut
+
+/// This small sout is the entry point of the transcode chain when the client
+/// request the media with a specific starting time (other than 0).
+/// This simple stream makes sure that nothing is sent before the
+/// flush operation right after the player seek.
+/// This is a hacky way to ensure the client receive no discontinuity caused
+/// by seeking at the beginning of the stream.
+namespace StreamOutProxy
+{
+int open( vlc_object_t* p_this );
+void close( vlc_object_t* p_this );
+} // namespace StreamOutProxy
+} // namespace Server
+
+#endif /* UPNP_SERVER_HPP */
diff --git a/modules/control/upnp_server/utils.cpp b/modules/control/upnp_server/utils.cpp
new file mode 100644
index 0000000000..0390b84c38
--- /dev/null
+++ b/modules/control/upnp_server/utils.cpp
@@ -0,0 +1,280 @@
+/*****************************************************************************
+ * Clients.cpp
+ *****************************************************************************
+ * Copyright © 2019 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#include "utils.hpp"
+
+#include <upnp.h>
+#include "../stream_out/dlna/dlna.hpp"
+
+#include <sstream>
+#include <functional>
+#include <algorithm>
+#include <iostream>
+
+namespace utils
+{
+std::string file_extension( const std::string& file ) noexcept
+{
+    auto pos = file.find_last_of( '.' );
+    if ( pos == std::string::npos )
+        return {};
+    return file.substr( pos + 1 );
+}
+
+std::string thumbnail_url( const vlc_ml_media_t& media,
+                           vlc_ml_thumbnail_size_t size ) noexcept
+{
+  std::stringstream ss;
+  const std::string s_size = size == VLC_ML_THUMBNAIL_SMALL ? "small" : "banner";
+  const auto & thumbnail = media.thumbnails[size];
+
+    const auto thumbnail_extension = file_extension( std::string( thumbnail.psz_mrl ) );
+    ss << get_server_url() << "thumbnail/"
+       << ( size == VLC_ML_THUMBNAIL_SMALL ? "small" : "banner" ) << "/media/" << media.i_id << '.'
+       << thumbnail_extension;
+
+    return ss.str();
+}
+
+std::string album_thumbnail_url( const vlc_ml_album_t& album ) noexcept
+{
+    const auto& thumbnail = album.thumbnails[VLC_ML_THUMBNAIL_SMALL];
+    if ( thumbnail.i_status != VLC_ML_THUMBNAIL_STATUS_AVAILABLE )
+        return "";
+    const auto thumbnail_extension = file_extension( std::string( thumbnail.psz_mrl ) );
+    return get_server_url() + "thumbnail/small/album/" + std::to_string( album.i_id ) + "." +
+           thumbnail_extension;
+}
+
+std::vector<MediaTrackRef> get_media_tracks(const vlc_ml_media_t& media, vlc_ml_track_type_t type) noexcept
+{
+  std::vector<MediaTrackRef> ret;
+
+  if (media.p_tracks == nullptr)
+    return ret;
+  for (unsigned i = 0; i < media.p_tracks->i_nb_items; ++i)
+  {
+    const auto& track = media.p_tracks->p_items[i];
+    if (track.i_type == type)
+      ret.emplace_back(track);
+  }
+  return ret;
+}
+
+std::vector<MediaFileRef> get_media_files(const vlc_ml_media_t& media, vlc_ml_file_type_t type) noexcept
+{
+  std::vector<MediaFileRef> ret;
+
+  if (media.p_files == nullptr)
+    return ret;
+  for (unsigned i = 0; i < media.p_files->i_nb_items; ++i)
+  {
+    const auto& file = media.p_files->p_items[i];
+    if (file.i_type == type)
+      ret.emplace_back(file);
+  }
+  return ret;
+}
+
+std::string get_server_url() noexcept 
+{
+    // TODO support ipv6
+    const std::string addr = UpnpGetServerIpAddress();
+    const std::string port = std::to_string( UpnpGetServerPort() );
+    return "http://" + addr + ':' + port + '/';
+}
+
+MimeType get_mimetype( vlc_ml_media_type_t type, const std::string& file_extension ) noexcept
+{
+    const char* mime_end = file_extension.c_str();
+    // special case for the transcode muxer to be widely accepted by a majority of players.
+    if ( file_extension == "ts" )
+        mime_end = "mpeg";
+    switch ( type )
+    {
+    case VLC_ML_MEDIA_TYPE_AUDIO:
+      return {"audio", mime_end};
+    case VLC_ML_MEDIA_TYPE_UNKNOWN: // Intended pass through
+      assert(!"Unknown media type");
+    case VLC_ML_MEDIA_TYPE_VIDEO:
+      return {"video", mime_end};
+    default:
+      assert(!"Invalid media type");
+  }
+}
+
+std::string get_root_dir() noexcept
+{
+    std::stringstream ret;
+
+    char* path = config_GetSysPath( VLC_PKG_DATA_DIR, NULL );
+    assert( path );
+
+    ret << path << "/upnp_server/";
+
+    free( path );
+    return ret.str();
+}
+
+// TODO We should change that to a better profile selection using profiles in dlna.hpp
+// as soon as more info on media tracks are available in the medialibrary
+std::string infer_dlna_profile_name( const std::string& extension ) noexcept
+{
+    if ( extension == "mp4" )
+        return "AVC_MP4_EU";
+
+    std::string profile;
+    std::transform( std::begin( extension ), std::end( extension ), std::begin( profile ),
+                    ::toupper );
+    return profile;
+}
+
+std::string get_dlna_extra_protocol_info( const std::string& file_type, const TranscodeProfile* profile ) noexcept
+{
+    std::ostringstream ret;
+
+    const auto dlna_profile = profile ? profile->mux.dlna_profile_name : infer_dlna_profile_name(file_type);
+
+    if ( !dlna_profile.empty() )
+        ret << "DLNA.ORG_PN=" << dlna_profile << ";";
+
+    dlna_org_flags_t flags = DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE |
+                             DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE |
+                             DLNA_ORG_FLAG_CONNECTION_STALL | DLNA_ORG_FLAG_DLNA_V15;
+    dlna_org_operation_t op;
+    dlna_org_conversion_t conv;
+    if ( profile )
+    {
+        flags = flags | DLNA_ORG_FLAG_TIME_BASED_SEEK;
+        op = DLNA_ORG_OPERATION_TIMESEEK;
+        conv = DLNA_ORG_CONVERSION_TRANSCODED;
+    }
+    else
+    {
+        op = DLNA_ORG_OPERATION_RANGE;
+        conv = DLNA_ORG_CONVERSION_NONE;
+    }
+
+    char dlna_info[448];
+    sprintf( dlna_info, "%s=%.2x;%s=%d;%s=%.8x%.24x", "DLNA.ORG_OP", op, "DLNA.ORG_CI", conv,
+             "DLNA.ORG_FLAGS", flags, 0 );
+    ret << dlna_info;
+    return ret.str();
+}
+
+// See RFC 2326 3.6 normal play time
+// We only support the first part of the range as the starting point of the player.
+time_t parse_dlna_npt_range( const char* value )
+{
+    using namespace std::chrono;
+    if ( strncmp( value, "npt", 3 ) != 0 )
+        return 0;
+
+    const auto next_token = [](const char *line, const char token) {
+      const char* ret = strchr(line, token);
+      if (!ret) throw std::invalid_argument("");
+      ret += 1;
+      if (!*ret || *ret == '-') throw std::invalid_argument("");
+      return ret;
+    };
+
+    const char *equal_idx;
+    try
+    {
+        equal_idx = next_token( value, '=' );
+    }
+    catch ( std::invalid_argument& )
+    {
+        return 0;
+    }
+
+    const char* token = equal_idx;
+    auto ret = 0ms;
+    try
+    {
+        ret += hours( std::stoll( token ) );
+        token = next_token( token, ':' );
+        ret += minutes( std::stoll( token ) );
+        token = next_token( token, ':' );
+    }
+    catch ( const std::invalid_argument& )
+    {
+        // If date pattern is not valid, fall back to seconds only parsing
+        ret = 0ms;
+        token = equal_idx;
+    }
+
+    try
+    {
+        size_t pos;
+        ret += milliseconds( static_cast<unsigned>( std::stod( token, &pos ) * 1000. ) );
+        if (token[pos] != '-')
+          return 0;
+        return ret.count();
+    }
+    catch ( const std::invalid_argument& )
+    {
+        return 0;
+    }
+}
+
+namespace http {
+UpnpExtraHeaders* get_hdr( UpnpListHead* list, const std::string name )
+{
+    for ( auto* it = UpnpListBegin( list ); it != UpnpListEnd( list );
+          it = UpnpListNext( list, it ) )
+    {
+        UpnpExtraHeaders* hd = reinterpret_cast<UpnpExtraHeaders*>( it );
+        std::string hdr_name = UpnpExtraHeaders_get_name_cstr( hd );
+//        std::cerr << hdr_name << ": " << UpnpExtraHeaders_get_value_cstr( hd ) << "\n";
+        std::transform( std::begin( hdr_name ), std::end( hdr_name ), std::begin( hdr_name ),
+                        ::tolower );
+        if ( hdr_name == name )
+        {
+            return hd;
+        }
+    }
+    return nullptr;
+}
+
+void add_response_hdr( UpnpListHead* list, const std::pair<std::string, std::string> resp )
+{
+
+    auto hdr = get_hdr( list, resp.first );
+    const auto resp_str = resp.first + ": " + resp.second;
+ //   std::cerr << "RESP: " << resp_str << '\n';
+    if ( !hdr )
+    {
+        hdr = UpnpExtraHeaders_new();
+    }
+    UpnpExtraHeaders_set_resp( hdr, resp_str.c_str() );
+    UpnpListInsert( list, UpnpListEnd( list ),
+                    const_cast<UpnpListHead*>( UpnpExtraHeaders_get_node( hdr ) ) );
+}
+
+const char* get_extra_header( UpnpListHead* list, const char* name )
+{
+    auto hdr = get_hdr( list, name );
+    return hdr != nullptr ? UpnpExtraHeaders_get_value_cstr( hdr ) : nullptr;
+}
+}
+
+} // namespace utils
diff --git a/modules/control/upnp_server/utils.hpp b/modules/control/upnp_server/utils.hpp
new file mode 100644
index 0000000000..b1382ab44c
--- /dev/null
+++ b/modules/control/upnp_server/utils.hpp
@@ -0,0 +1,93 @@
+/*****************************************************************************
+ * utils.hpp : UPnP server utils
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef UTILS_HPP
+#define UTILS_HPP
+
+#include "Profiles.hpp"
+#include <vlc_media_library.h>
+
+#include <upnp.h>
+#include <ExtraHeaders.h>
+
+#include <string>
+#include <vector>
+#include <chrono>
+#include <sstream>
+#include <iomanip>
+
+namespace utils
+{
+std::string file_extension( const std::string& file ) noexcept;
+
+struct MimeType
+{
+    std::string media_type;
+    std::string file_type;
+
+    std::string combine() const noexcept { return media_type + '/' + file_type; }
+};
+
+MimeType get_mimetype( vlc_ml_media_type_t type, const std::string& file_extension ) noexcept;
+
+std::string thumbnail_url( const vlc_ml_media_t& media,
+                           vlc_ml_thumbnail_size_t size ) noexcept;
+std::string album_thumbnail_url(const vlc_ml_album_t &) noexcept;
+
+std::string get_server_url() noexcept;
+std::string get_root_dir() noexcept;
+
+template <typename T>
+using ConstRef = std::reference_wrapper<const T>;
+
+using MediaTrackRef = ConstRef<vlc_ml_media_track_t>;
+std::vector<MediaTrackRef> get_media_tracks(const vlc_ml_media_t& media, vlc_ml_track_type_t type) noexcept;
+
+using MediaFileRef = ConstRef<vlc_ml_file_t>;
+std::vector<MediaFileRef> get_media_files(const vlc_ml_media_t& media, vlc_ml_file_type_t) noexcept;
+
+std::string get_dlna_extra_protocol_info( const std::string& dlna_profile,
+                                          const TranscodeProfile* ) noexcept;
+std::string infer_dlna_profile_name( const std::string& extension ) noexcept;
+
+template <typename Rep, typename Pediod = std::ratio<1>>
+std::string duration_to_string( const char* fmt, std::chrono::duration<Rep, Pediod> duration )
+{
+    char ret[32] = { 0 };
+    using namespace std::chrono;
+    // Substract 1 hour because std::localtime starts at 1 AM
+    const time_t sec = duration_cast<seconds>( duration - 1h ).count();
+    const size_t size = std::strftime( ret, sizeof( ret ), fmt, std::localtime( &sec ) );
+    return std::string{ ret, size };
+}
+
+time_t parse_dlna_npt_range( const char* value );
+
+namespace http
+{
+UpnpExtraHeaders* get_hdr( UpnpListHead* list, const std::string name );
+void add_response_hdr( UpnpListHead* list, const std::pair<std::string, std::string> resp );
+const char* get_extra_header( UpnpListHead* list, const char* name );
+} // namespace http
+} // namespace utils
+
+#endif /* UTILS_HPP */
diff --git a/modules/control/upnp_server/xml_wrapper.hpp b/modules/control/upnp_server/xml_wrapper.hpp
new file mode 100644
index 0000000000..6acb01ce28
--- /dev/null
+++ b/modules/control/upnp_server/xml_wrapper.hpp
@@ -0,0 +1,130 @@
+/*****************************************************************************
+ * xml_wrapper.hpp : Modern C++ xmli wrapper
+ *****************************************************************************
+ * Copyright © 2021 VLC authors and VideoLAN
+ *
+ * Authors: Alaric Senat <dev.asenat at posteo.net>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#pragma once
+#ifndef XML_WRAPPER_HPP
+#define XML_WRAPPER_HPP
+
+#include <ixml.h>
+
+#include <cassert>
+
+#include <memory>
+
+/// Simple C++ wrapper around libixml xml library.
+namespace xml
+{
+
+struct Document;
+
+struct Node
+{
+    using Ptr = std::unique_ptr<IXML_Node, decltype( &ixmlNode_free )>;
+    Ptr ptr;
+};
+
+struct Element
+{
+    using Ptr = std::unique_ptr<IXML_Element, decltype( &ixmlElement_free )>;
+
+    Ptr ptr;
+    Document& owner;
+
+    void set_attribute( const char* name, const char* value )
+    {
+        assert( ptr != nullptr );
+        ixmlElement_setAttribute( ptr.get(), name, value );
+    }
+
+    void add_child( Node child )
+    {
+        assert( ptr != nullptr );
+        assert( child.ptr != nullptr );
+        ixmlNode_appendChild( &ptr->n, child.ptr.release() );
+    }
+
+    void add_child( Element child )
+    {
+        assert( ptr != nullptr );
+        assert( child.ptr != nullptr );
+        ixmlNode_appendChild( &ptr->n, &child.ptr.release()->n );
+    }
+
+    template <typename Child>
+    void add_children( Child&& child )
+    {
+        if ( child.ptr != nullptr )
+            add_child( std::move( child ) );
+    }
+
+    template <typename First, typename... Child>
+    void add_children( First&& first, Child&&... children )
+    {
+        add_child( std::move( first ) );
+        add_children( std::move( children )... );
+    }
+
+    private:
+    void add_child() {}
+};
+
+struct Document
+{
+    using Ptr = std::unique_ptr<IXML_Document, decltype( &ixmlDocument_free )>;
+    Ptr ptr;
+
+    Document() : ptr{ ixmlDocument_createDocument(), &ixmlDocument_free } {}
+    Document( Ptr ) = delete;
+    Document( Ptr&& ) = delete;
+
+    Node create_text_node( const char* text )
+    {
+        return Node{ Node::Ptr{ ixmlDocument_createTextNode( ptr.get(), text ), ixmlNode_free } };
+    }
+
+    Element create_element( const char* name )
+    {
+        return Element{
+            Element::Ptr{ ixmlDocument_createElement( ptr.get(), name ), &ixmlElement_free },
+            *this };
+    }
+
+    template <typename... Children>
+    Element create_element( const char* name, Children&&... children )
+    {
+        Element ret = create_element( name );
+        ret.add_children( children... );
+        return ret;
+    }
+
+    void set_entry( Element&& entry ) { ixmlNode_appendChild( &ptr->n, &entry.ptr.release()->n ); }
+
+    using WrappedDOMString = std::unique_ptr<char, decltype( &ixmlFreeDOMString )>;
+
+    WrappedDOMString to_wrapped_cstr() const
+    {
+        return WrappedDOMString{ ixmlDocumenttoString( ptr.get() ), &ixmlFreeDOMString };
+    }
+};
+
+} // namespace xml
+
+#endif /* XML_WRAPPER_HPP */
diff --git a/modules/services_discovery/Makefile.am b/modules/services_discovery/Makefile.am
index a1105282ff..45d836b31a 100644
--- a/modules/services_discovery/Makefile.am
+++ b/modules/services_discovery/Makefile.am
@@ -31,16 +31,55 @@ libupnp_plugin_la_SOURCES = services_discovery/upnp.cpp services_discovery/upnp.
 			    stream_out/dlna/profile_names.hpp \
 			    stream_out/dlna/dlna_common.hpp \
 			    stream_out/dlna/dlna.hpp \
-			    stream_out/dlna/dlna.cpp
+			    stream_out/dlna/dlna.cpp \
+					control/upnp_server/upnp_server.hpp \
+          control/upnp_server/xml/Description.h \
+          control/upnp_server/upnp_server.cpp \
+          control/upnp_server/FileHandler.hpp \
+          control/upnp_server/FileHandler.cpp \
+          control/upnp_server/sout.cpp \
+					control/upnp_server/Option.hpp \
+					control/upnp_server/ml.hpp \
+					control/upnp_server/utils.hpp \
+					control/upnp_server/utils.cpp \
+					control/upnp_server/cds/Object.hpp \
+					control/upnp_server/cds/Container.hpp \
+					control/upnp_server/cds/FixedContainer.hpp \
+					control/upnp_server/cds/FixedContainer.cpp \
+					control/upnp_server/cds/Item.hpp \
+					control/upnp_server/cds/Item.cpp \
+					control/upnp_server/cds/MLContainer.hpp \
+					control/upnp_server/cds/MLContainerList.hpp \
+					control/upnp_server/cds/cds.hpp \
+					control/upnp_server/cds/cds.cpp \
+					control/upnp_server/Clients.cpp
 libupnp_plugin_la_CXXFLAGS = $(AM_CXXFLAGS) $(UPNP_CFLAGS)
 libupnp_plugin_la_LDFLAGS = $(AM_LDFLAGS) -rpath '$(sddir)' -lpthread
 libupnp_plugin_la_LIBADD = $(UPNP_LIBS)
+libupnp_plugin_la_RES = control/upnp_server/share/ContentDirectory.xml \
+					control/upnp_server/share/ConnectionManager.xml \
+					control/upnp_server/share/X_MS_MediaReceiverRegistrar.xml \
+					../share/icons/32x32/vlc.png \
+					../share/vlc512x512.png
+dist_libupnp_plugin_la_DATA = $(libupnp_plugin_la_RES)
+libupnp_plugin_ladir = $(prefix)/share/vlc/upnp_server
 EXTRA_LTLIBRARIES += libupnp_plugin.la
 sd_LTLIBRARIES += $(LTLIBupnp)
 if HAVE_OSX
 libupnp_plugin_la_LDFLAGS += -Wl,-framework,CoreFoundation,-framework,SystemConfiguration
 endif
 
+upnp_server_test_SOURCES = \
+    control/upnp_server/test/main.cpp \
+    control/upnp_server/test/cds.cpp \
+    control/upnp_server/test/utils.cpp \
+		$(libupnp_plugin_la_SOURCES)
+upnp_server_test_CXXFLAGS = $(UPNP_CFLAGS)
+upnp_server_test_LDFLAGS = $(AM_LDFLAGS) -rpath '$(sddir)'
+upnp_server_test_LDADD = $(UPNP_LIBS)
+check_PROGRAMS += upnp_server_test
+TESTS += upnp_server_test
+
 libpulselist_plugin_la_SOURCES = services_discovery/pulse.c
 libpulselist_plugin_la_CFLAGS = $(AM_CFLAGS) $(PULSE_CFLAGS)
 libpulselist_plugin_la_LIBADD = libvlc_pulse.la $(PULSE_LIBS)
diff --git a/modules/services_discovery/upnp.cpp b/modules/services_discovery/upnp.cpp
index a249e3b4d2..65787ecbeb 100644
--- a/modules/services_discovery/upnp.cpp
+++ b/modules/services_discovery/upnp.cpp
@@ -180,6 +180,26 @@ vlc_module_begin()
         add_string(SOUT_CFG_PREFIX "base_url", NULL, BASE_URL_TEXT, BASE_URL_LONGTEXT, false)
         add_string(SOUT_CFG_PREFIX "url", NULL, URL_TEXT, URL_LONGTEXT, false)
         add_renderer_opts(SOUT_CFG_PREFIX)
+
+    add_submodule()
+        set_shortname( "UPnP Server" );
+        set_description( N_( "Universal Plug'n'Play Server" ) );
+        set_category( CAT_INTERFACE );
+        set_subcategory( SUBCAT_INTERFACE_MAIN );
+        set_capability( "interface", 0 );
+        set_callbacks( Server::open, Server::close );
+
+        add_string(SERVER_PREFIX "name", SERVER_DEFAULT_NAME, SERVER_NAME_DESC, SERVER_NAME_LONGTEXT, false)
+
+    add_submodule()
+        add_shortcut( "upnp-out" )
+        set_capability( "sout access", 0 );
+        set_callbacks( Server::AccessOut::open, Server::AccessOut::close );
+    add_submodule()
+        add_shortcut("upnp-sout-proxy")
+        set_capability("sout filter", 0)
+        set_callbacks(Server::StreamOutProxy::open, Server::StreamOutProxy::close)
+
 vlc_module_end()
 
 /*
diff --git a/modules/services_discovery/upnp.hpp b/modules/services_discovery/upnp.hpp
index ae5894bca5..32a2b2f3cb 100644
--- a/modules/services_discovery/upnp.hpp
+++ b/modules/services_discovery/upnp.hpp
@@ -29,6 +29,7 @@
 
 #include "upnp-wrapper.hpp"
 #include "../stream_out/dlna/dlna_common.hpp"
+#include "../control/upnp_server/upnp_server.hpp"
 
 #include <vlc_url.h>
 #include <vlc_interrupt.h>
-- 
2.29.2





More information about the vlc-devel mailing list