aboutsummaryrefslogtreecommitdiffstats
path: root/src/db/plugins/upnp
diff options
context:
space:
mode:
Diffstat (limited to 'src/db/plugins/upnp')
-rw-r--r--src/db/plugins/upnp/ContentDirectoryService.cxx204
-rw-r--r--src/db/plugins/upnp/Directory.cxx262
-rw-r--r--src/db/plugins/upnp/Directory.hxx66
-rw-r--r--src/db/plugins/upnp/Object.cxx25
-rw-r--r--src/db/plugins/upnp/Object.hxx85
-rw-r--r--src/db/plugins/upnp/Tags.cxx33
-rw-r--r--src/db/plugins/upnp/Tags.hxx28
-rw-r--r--src/db/plugins/upnp/UpnpDatabasePlugin.cxx786
-rw-r--r--src/db/plugins/upnp/UpnpDatabasePlugin.hxx27
9 files changed, 1516 insertions, 0 deletions
diff --git a/src/db/plugins/upnp/ContentDirectoryService.cxx b/src/db/plugins/upnp/ContentDirectoryService.cxx
new file mode 100644
index 000000000..c097f7644
--- /dev/null
+++ b/src/db/plugins/upnp/ContentDirectoryService.cxx
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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 "config.h"
+#include "lib/upnp/ContentDirectoryService.hxx"
+#include "lib/upnp/Domain.hxx"
+#include "lib/upnp/ixmlwrap.hxx"
+#include "lib/upnp/Action.hxx"
+#include "Directory.hxx"
+#include "util/NumberParser.hxx"
+#include "util/Error.hxx"
+
+#include <stdio.h>
+
+static bool
+ReadResultTag(UPnPDirContent &dirbuf, IXML_Document *response, Error &error)
+{
+ const char *p = ixmlwrap::getFirstElementValue(response, "Result");
+ if (p == nullptr)
+ p = "";
+
+ return dirbuf.parse(p, error);
+}
+
+inline bool
+ContentDirectoryService::readDirSlice(UpnpClient_Handle hdl,
+ const char *objectId, unsigned offset,
+ unsigned count, UPnPDirContent &dirbuf,
+ unsigned &didreadp, unsigned &totalp,
+ Error &error) const
+{
+ // Create request
+ char ofbuf[100], cntbuf[100];
+ sprintf(ofbuf, "%u", offset);
+ sprintf(cntbuf, "%u", count);
+ // Some devices require an empty SortCriteria, else bad params
+ IXML_Document *request =
+ MakeActionHelper("Browse", m_serviceType.c_str(),
+ "ObjectID", objectId,
+ "BrowseFlag", "BrowseDirectChildren",
+ "Filter", "*",
+ "SortCriteria", "",
+ "StartingIndex", ofbuf,
+ "RequestedCount", cntbuf);
+ if (request == nullptr) {
+ error.Set(upnp_domain, "UpnpMakeAction() failed");
+ return false;
+ }
+
+ IXML_Document *response;
+ int code = UpnpSendAction(hdl, m_actionURL.c_str(), m_serviceType.c_str(),
+ 0 /*devUDN*/, request, &response);
+ ixmlDocument_free(request);
+ if (code != UPNP_E_SUCCESS) {
+ error.Format(upnp_domain, code,
+ "UpnpSendAction() failed: %s",
+ UpnpGetErrorMessage(code));
+ return false;
+ }
+
+ const char *value = ixmlwrap::getFirstElementValue(response, "NumberReturned");
+ didreadp = value != nullptr
+ ? ParseUnsigned(value)
+ : 0;
+
+ value = ixmlwrap::getFirstElementValue(response, "TotalMatches");
+ if (value != nullptr)
+ totalp = ParseUnsigned(value);
+
+ bool success = ReadResultTag(dirbuf, response, error);
+ ixmlDocument_free(response);
+ return success;
+}
+
+bool
+ContentDirectoryService::readDir(UpnpClient_Handle handle,
+ const char *objectId,
+ UPnPDirContent &dirbuf,
+ Error &error) const
+{
+ unsigned offset = 0, total = -1, count;
+
+ do {
+ if (!readDirSlice(handle, objectId, offset, m_rdreqcnt, dirbuf,
+ count, total, error))
+ return false;
+
+ offset += count;
+ } while (count > 0 && offset < total);
+
+ return true;
+}
+
+bool
+ContentDirectoryService::search(UpnpClient_Handle hdl,
+ const char *objectId,
+ const char *ss,
+ UPnPDirContent &dirbuf,
+ Error &error) const
+{
+ unsigned offset = 0, total = -1, count;
+
+ do {
+ char ofbuf[100];
+ sprintf(ofbuf, "%d", offset);
+
+ IXML_Document *request =
+ MakeActionHelper("Search", m_serviceType.c_str(),
+ "ContainerID", objectId,
+ "SearchCriteria", ss,
+ "Filter", "*",
+ "SortCriteria", "",
+ "StartingIndex", ofbuf,
+ "RequestedCount", "0"); // Setting a value here gets twonky into fits
+ if (request == 0) {
+ error.Set(upnp_domain, "UpnpMakeAction() failed");
+ return false;
+ }
+
+ IXML_Document *response;
+ auto code = UpnpSendAction(hdl, m_actionURL.c_str(),
+ m_serviceType.c_str(),
+ 0 /*devUDN*/, request, &response);
+ ixmlDocument_free(request);
+ if (code != UPNP_E_SUCCESS) {
+ error.Format(upnp_domain, code,
+ "UpnpSendAction() failed: %s",
+ UpnpGetErrorMessage(code));
+ return false;
+ }
+
+ const char *value =
+ ixmlwrap::getFirstElementValue(response, "NumberReturned");
+ count = value != nullptr
+ ? ParseUnsigned(value)
+ : 0;
+
+ offset += count;
+
+ value = ixmlwrap::getFirstElementValue(response, "TotalMatches");
+ if (value != nullptr)
+ total = ParseUnsigned(value);
+
+ bool success = ReadResultTag(dirbuf, response, error);
+ ixmlDocument_free(response);
+ if (!success)
+ return false;
+ } while (count > 0 && offset < total);
+
+ return true;
+}
+
+bool
+ContentDirectoryService::getMetadata(UpnpClient_Handle hdl,
+ const char *objectId,
+ UPnPDirContent &dirbuf,
+ Error &error) const
+{
+ // Create request
+ IXML_Document *request =
+ MakeActionHelper("Browse", m_serviceType.c_str(),
+ "ObjectID", objectId,
+ "BrowseFlag", "BrowseMetadata",
+ "Filter", "*",
+ "SortCriteria", "",
+ "StartingIndex", "0",
+ "RequestedCount", "1");
+ if (request == nullptr) {
+ error.Set(upnp_domain, "UpnpMakeAction() failed");
+ return false;
+ }
+
+ IXML_Document *response;
+ auto code = UpnpSendAction(hdl, m_actionURL.c_str(),
+ m_serviceType.c_str(),
+ 0 /*devUDN*/, request, &response);
+ ixmlDocument_free(request);
+ if (code != UPNP_E_SUCCESS) {
+ error.Format(upnp_domain, code,
+ "UpnpSendAction() failed: %s",
+ UpnpGetErrorMessage(code));
+ return false;
+ }
+
+ bool success = ReadResultTag(dirbuf, response, error);
+ ixmlDocument_free(response);
+ return success;
+}
diff --git a/src/db/plugins/upnp/Directory.cxx b/src/db/plugins/upnp/Directory.cxx
new file mode 100644
index 000000000..e43cd48a6
--- /dev/null
+++ b/src/db/plugins/upnp/Directory.cxx
@@ -0,0 +1,262 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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 "config.h"
+#include "Directory.hxx"
+#include "lib/upnp/Util.hxx"
+#include "lib/expat/ExpatParser.hxx"
+#include "Tags.hxx"
+#include "tag/TagBuilder.hxx"
+#include "tag/TagTable.hxx"
+#include "util/NumberParser.hxx"
+
+#include <algorithm>
+#include <string>
+
+#include <string.h>
+
+UPnPDirContent::~UPnPDirContent()
+{
+ /* this destructor exists here just so it won't get inlined */
+}
+
+gcc_pure gcc_nonnull_all
+static bool
+CompareStringLiteral(const char *literal, const char *value, size_t length)
+{
+ return length == strlen(literal) &&
+ memcmp(literal, value, length) == 0;
+}
+
+gcc_pure
+static UPnPDirObject::ItemClass
+ParseItemClass(const char *name, size_t length)
+{
+ if (CompareStringLiteral("object.item.audioItem.musicTrack",
+ name, length))
+ return UPnPDirObject::ItemClass::MUSIC;
+ else if (CompareStringLiteral("object.item.playlistItem",
+ name, length))
+ return UPnPDirObject::ItemClass::PLAYLIST;
+ else
+ return UPnPDirObject::ItemClass::UNKNOWN;
+}
+
+gcc_pure
+static int
+ParseDuration(const char *duration)
+{
+ char *endptr;
+
+ unsigned result = ParseUnsigned(duration, &endptr);
+ if (endptr == duration || *endptr != ':')
+ return 0;
+
+ result *= 60;
+ duration = endptr + 1;
+ result += ParseUnsigned(duration, &endptr);
+ if (endptr == duration || *endptr != ':')
+ return 0;
+
+ result *= 60;
+ duration = endptr + 1;
+ result += ParseUnsigned(duration, &endptr);
+ if (endptr == duration || *endptr != 0)
+ return 0;
+
+ return result;
+}
+
+/**
+ * Transform titles to turn '/' into '_' to make them acceptable path
+ * elements. There is a very slight risk of collision in doing
+ * this. Twonky returns directory names (titles) like 'Artist/Album'.
+ */
+gcc_pure
+static std::string
+titleToPathElt(std::string &&s)
+{
+ std::replace(s.begin(), s.end(), '/', '_');
+ return s;
+}
+
+/**
+ * An XML parser which builds directory contents from DIDL lite input.
+ */
+class UPnPDirParser final : public CommonExpatParser {
+ UPnPDirContent &m_dir;
+
+ enum {
+ NONE,
+ RES,
+ CLASS,
+ } state;
+
+ /**
+ * If not equal to #TAG_NUM_OF_ITEM_TYPES, then we're
+ * currently reading an element containing a tag value. The
+ * value is being constructed in #value.
+ */
+ TagType tag_type;
+
+ /**
+ * The text inside the current element.
+ */
+ std::string value;
+
+ UPnPDirObject m_tobj;
+ TagBuilder tag;
+
+public:
+ UPnPDirParser(UPnPDirContent& dir)
+ :m_dir(dir),
+ state(NONE),
+ tag_type(TAG_NUM_OF_ITEM_TYPES)
+ {
+ }
+
+protected:
+ virtual void StartElement(const XML_Char *name, const XML_Char **attrs)
+ {
+ if (m_tobj.type != UPnPDirObject::Type::UNKNOWN &&
+ tag_type == TAG_NUM_OF_ITEM_TYPES) {
+ tag_type = tag_table_lookup(upnp_tags, name);
+ if (tag_type != TAG_NUM_OF_ITEM_TYPES)
+ return;
+ } else {
+ assert(tag_type == TAG_NUM_OF_ITEM_TYPES);
+ }
+
+ switch (name[0]) {
+ case 'c':
+ if (!strcmp(name, "container")) {
+ m_tobj.clear();
+ m_tobj.type = UPnPDirObject::Type::CONTAINER;
+
+ const char *id = GetAttribute(attrs, "id");
+ if (id != nullptr)
+ m_tobj.m_id = id;
+
+ const char *pid = GetAttribute(attrs, "parentID");
+ if (pid != nullptr)
+ m_tobj.m_pid = pid;
+ }
+ break;
+
+ case 'i':
+ if (!strcmp(name, "item")) {
+ m_tobj.clear();
+ m_tobj.type = UPnPDirObject::Type::ITEM;
+
+ const char *id = GetAttribute(attrs, "id");
+ if (id != nullptr)
+ m_tobj.m_id = id;
+
+ const char *pid = GetAttribute(attrs, "parentID");
+ if (pid != nullptr)
+ m_tobj.m_pid = pid;
+ }
+ break;
+
+ case 'r':
+ if (!strcmp(name, "res")) {
+ // <res protocolInfo="http-get:*:audio/mpeg:*" size="5171496"
+ // bitrate="24576" duration="00:03:35" sampleFrequency="44100"
+ // nrAudioChannels="2">
+
+ const char *duration =
+ GetAttribute(attrs, "duration");
+ if (duration != nullptr)
+ tag.SetTime(ParseDuration(duration));
+
+ state = RES;
+ }
+
+ break;
+
+ case 'u':
+ if (strcmp(name, "upnp:class") == 0)
+ state = CLASS;
+ }
+ }
+
+ bool checkobjok() {
+ if (m_tobj.m_id.empty() || m_tobj.m_pid.empty() ||
+ m_tobj.name.empty() ||
+ (m_tobj.type == UPnPDirObject::Type::ITEM &&
+ m_tobj.item_class == UPnPDirObject::ItemClass::UNKNOWN))
+ return false;
+
+ return true;
+ }
+
+ virtual void EndElement(const XML_Char *name)
+ {
+ if (tag_type != TAG_NUM_OF_ITEM_TYPES) {
+ assert(m_tobj.type != UPnPDirObject::Type::UNKNOWN);
+
+ tag.AddItem(tag_type, value.c_str());
+
+ if (tag_type == TAG_TITLE)
+ m_tobj.name = titleToPathElt(std::move(value));
+
+ value.clear();
+ tag_type = TAG_NUM_OF_ITEM_TYPES;
+ return;
+ }
+
+ if ((!strcmp(name, "container") || !strcmp(name, "item")) &&
+ checkobjok()) {
+ tag.Commit(m_tobj.tag);
+ m_dir.objects.emplace_back(std::move(m_tobj));
+ }
+
+ state = NONE;
+ }
+
+ virtual void CharacterData(const XML_Char *s, int len)
+ {
+ if (tag_type != TAG_NUM_OF_ITEM_TYPES) {
+ assert(m_tobj.type != UPnPDirObject::Type::UNKNOWN);
+
+ value.append(s, len);
+ return;
+ }
+
+ switch (state) {
+ case NONE:
+ break;
+
+ case RES:
+ m_tobj.url.assign(s, len);
+ break;
+
+ case CLASS:
+ m_tobj.item_class = ParseItemClass(s, len);
+ break;
+ }
+ }
+};
+
+bool
+UPnPDirContent::parse(const char *input, Error &error)
+{
+ UPnPDirParser parser(*this);
+ return parser.Parse(input, strlen(input), true, error);
+}
diff --git a/src/db/plugins/upnp/Directory.hxx b/src/db/plugins/upnp/Directory.hxx
new file mode 100644
index 000000000..433979900
--- /dev/null
+++ b/src/db/plugins/upnp/Directory.hxx
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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.
+ */
+
+#ifndef MPD_UPNP_DIRECTORY_HXX
+#define MPD_UPNP_DIRECTORY_HXX
+
+#include "Object.hxx"
+#include "Compiler.h"
+
+#include <string>
+#include <vector>
+
+class Error;
+
+/**
+ * Image of a MediaServer Directory Service container (directory),
+ * possibly containing items and subordinate containers.
+ */
+class UPnPDirContent {
+public:
+ std::vector<UPnPDirObject> objects;
+
+ ~UPnPDirContent();
+
+ gcc_pure
+ UPnPDirObject *FindObject(const char *name) {
+ for (auto &o : objects)
+ if (o.name == name)
+ return &o;
+
+ return nullptr;
+ }
+
+ /**
+ * Parse from DIDL-Lite XML data.
+ *
+ * Normally only used by ContentDirectoryService::readDir()
+ * This is cumulative: in general, the XML data is obtained in
+ * several documents corresponding to (offset,count) slices of the
+ * directory (container). parse() can be called repeatedly with
+ * the successive XML documents and will accumulate entries in the item
+ * and container vectors. This makes more sense if the different
+ * chunks are from the same container, but given that UPnP Ids are
+ * actually global, nothing really bad will happen if you mix
+ * up...
+ */
+ bool parse(const char *didltext, Error &error);
+};
+
+#endif /* _UPNPDIRCONTENT_H_X_INCLUDED_ */
diff --git a/src/db/plugins/upnp/Object.cxx b/src/db/plugins/upnp/Object.cxx
new file mode 100644
index 000000000..703fb0be4
--- /dev/null
+++ b/src/db/plugins/upnp/Object.cxx
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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 "Object.hxx"
+
+UPnPDirObject::~UPnPDirObject()
+{
+ /* this destructor exists here just so it won't get inlined */
+}
diff --git a/src/db/plugins/upnp/Object.hxx b/src/db/plugins/upnp/Object.hxx
new file mode 100644
index 000000000..16a66c774
--- /dev/null
+++ b/src/db/plugins/upnp/Object.hxx
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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.
+ */
+
+#ifndef MPD_UPNP_OBJECT_HXX
+#define MPD_UPNP_OBJECT_HXX
+
+#include "tag/Tag.hxx"
+
+#include <string>
+
+/**
+ * UpnP Media Server directory entry, converted from XML data.
+ *
+ * This is a dumb data holder class, a struct with helpers.
+ */
+class UPnPDirObject {
+public:
+ enum class Type {
+ UNKNOWN,
+ ITEM,
+ CONTAINER,
+ };
+
+ // There are actually several kinds of containers:
+ // object.container.storageFolder, object.container.person,
+ // object.container.playlistContainer etc., but they all seem to
+ // behave the same as far as we're concerned. Otoh, musicTrack
+ // items are special to us, and so should playlists, but I've not
+ // seen one of the latter yet (servers seem to use containers for
+ // playlists).
+ enum class ItemClass {
+ UNKNOWN,
+ MUSIC,
+ PLAYLIST,
+ };
+
+ std::string m_id; // ObjectId
+ std::string m_pid; // Parent ObjectId
+ std::string url;
+
+ /**
+ * A copy of "dc:title" sanitized as a file name.
+ */
+ std::string name;
+
+ Type type;
+ ItemClass item_class;
+
+ Tag tag;
+
+ UPnPDirObject() = default;
+ UPnPDirObject(UPnPDirObject &&) = default;
+
+ ~UPnPDirObject();
+
+ UPnPDirObject &operator=(UPnPDirObject &&) = default;
+
+ void clear()
+ {
+ m_id.clear();
+ m_pid.clear();
+ url.clear();
+ type = Type::UNKNOWN;
+ item_class = ItemClass::UNKNOWN;
+ tag.Clear();
+ }
+};
+
+#endif /* _UPNPDIRCONTENT_H_X_INCLUDED_ */
diff --git a/src/db/plugins/upnp/Tags.cxx b/src/db/plugins/upnp/Tags.cxx
new file mode 100644
index 000000000..fd65df4d0
--- /dev/null
+++ b/src/db/plugins/upnp/Tags.cxx
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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 "config.h"
+#include "Tags.hxx"
+#include "tag/TagTable.hxx"
+
+const struct tag_table upnp_tags[] = {
+ { "upnp:artist", TAG_ARTIST },
+ { "upnp:album", TAG_ALBUM },
+ { "upnp:originalTrackNumber", TAG_TRACK },
+ { "upnp:genre", TAG_GENRE },
+ { "dc:title", TAG_TITLE },
+
+ /* sentinel */
+ { nullptr, TAG_NUM_OF_ITEM_TYPES }
+};
diff --git a/src/db/plugins/upnp/Tags.hxx b/src/db/plugins/upnp/Tags.hxx
new file mode 100644
index 000000000..ec6d18478
--- /dev/null
+++ b/src/db/plugins/upnp/Tags.hxx
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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.
+ */
+
+#ifndef MPD_UPNP_TAGS_HXX
+#define MPD_UPNP_TAGS_HXX
+
+/**
+ * Map UPnP property names to MPD tags.
+ */
+extern const struct tag_table upnp_tags[];
+
+#endif
diff --git a/src/db/plugins/upnp/UpnpDatabasePlugin.cxx b/src/db/plugins/upnp/UpnpDatabasePlugin.cxx
new file mode 100644
index 000000000..66951f402
--- /dev/null
+++ b/src/db/plugins/upnp/UpnpDatabasePlugin.cxx
@@ -0,0 +1,786 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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 "config.h"
+#include "UpnpDatabasePlugin.hxx"
+#include "Directory.hxx"
+#include "Tags.hxx"
+#include "lib/upnp/Domain.hxx"
+#include "lib/upnp/ClientInit.hxx"
+#include "lib/upnp/Discovery.hxx"
+#include "lib/upnp/ContentDirectoryService.hxx"
+#include "lib/upnp/Util.hxx"
+#include "db/Interface.hxx"
+#include "db/DatabasePlugin.hxx"
+#include "db/Selection.hxx"
+#include "db/DatabaseError.hxx"
+#include "db/LightDirectory.hxx"
+#include "db/LightSong.hxx"
+#include "db/Stats.hxx"
+#include "config/ConfigData.hxx"
+#include "tag/TagBuilder.hxx"
+#include "tag/TagTable.hxx"
+#include "util/Error.hxx"
+#include "util/Domain.hxx"
+#include "fs/Traits.hxx"
+#include "Log.hxx"
+#include "SongFilter.hxx"
+
+#include <string>
+#include <vector>
+#include <set>
+
+#include <assert.h>
+#include <string.h>
+
+static const char *const rootid = "0";
+
+class UpnpSong : public LightSong {
+ std::string uri2, real_uri2;
+
+ Tag tag2;
+
+public:
+ UpnpSong(UPnPDirObject &&object, std::string &&_uri)
+ :uri2(std::move(_uri)),
+ real_uri2(std::move(object.url)),
+ tag2(std::move(object.tag)) {
+ directory = nullptr;
+ uri = uri2.c_str();
+ real_uri = real_uri2.c_str();
+ tag = &tag2;
+ mtime = 0;
+ start_ms = end_ms = 0;
+ }
+};
+
+class UpnpDatabase : public Database {
+ UpnpClient_Handle handle;
+ UPnPDeviceDirectory *discovery;
+
+public:
+ UpnpDatabase():Database(upnp_db_plugin) {}
+
+ static Database *Create(EventLoop &loop, DatabaseListener &listener,
+ const config_param &param,
+ Error &error);
+
+ virtual bool Open(Error &error) override;
+ virtual void Close() override;
+ virtual const LightSong *GetSong(const char *uri_utf8,
+ Error &error) const override;
+ virtual void ReturnSong(const LightSong *song) const;
+
+ virtual bool Visit(const DatabaseSelection &selection,
+ VisitDirectory visit_directory,
+ VisitSong visit_song,
+ VisitPlaylist visit_playlist,
+ Error &error) const override;
+
+ virtual bool VisitUniqueTags(const DatabaseSelection &selection,
+ TagType tag_type, uint32_t group_mask,
+ VisitTag visit_tag,
+ Error &error) const override;
+
+ virtual bool GetStats(const DatabaseSelection &selection,
+ DatabaseStats &stats,
+ Error &error) const override;
+ virtual time_t GetUpdateStamp() const {return 0;}
+
+protected:
+ bool Configure(const config_param &param, Error &error);
+
+private:
+ bool VisitServer(const ContentDirectoryService &server,
+ const std::list<std::string> &vpath,
+ const DatabaseSelection &selection,
+ VisitDirectory visit_directory,
+ VisitSong visit_song,
+ VisitPlaylist visit_playlist,
+ Error &error) const;
+
+ /**
+ * Run an UPnP search according to MPD parameters, and
+ * visit_song the results.
+ */
+ bool SearchSongs(const ContentDirectoryService &server,
+ const char *objid,
+ const DatabaseSelection &selection,
+ VisitSong visit_song,
+ Error &error) const;
+
+ bool SearchSongs(const ContentDirectoryService &server,
+ const char *objid,
+ const DatabaseSelection &selection,
+ UPnPDirContent& dirbuf,
+ Error &error) const;
+
+ bool Namei(const ContentDirectoryService &server,
+ const std::list<std::string> &vpath,
+ UPnPDirObject &dirent,
+ Error &error) const;
+
+ /**
+ * Take server and objid, return metadata.
+ */
+ bool ReadNode(const ContentDirectoryService &server,
+ const char *objid, UPnPDirObject& dirent,
+ Error &error) const;
+
+ /**
+ * Get the path for an object Id. This works much like pwd,
+ * except easier cause our inodes have a parent id. Not used
+ * any more actually (see comments in SearchSongs).
+ */
+ bool BuildPath(const ContentDirectoryService &server,
+ const UPnPDirObject& dirent, std::string &idpath,
+ Error &error) const;
+};
+
+Database *
+UpnpDatabase::Create(gcc_unused EventLoop &loop,
+ gcc_unused DatabaseListener &listener,
+ const config_param &param, Error &error)
+{
+ UpnpDatabase *db = new UpnpDatabase();
+ if (!db->Configure(param, error)) {
+ delete db;
+ return nullptr;
+ }
+
+ /* libupnp loses its ability to receive multicast messages
+ apparently due to daemonization; using the LazyDatabase
+ wrapper works around this problem */
+ return db;
+}
+
+inline bool
+UpnpDatabase::Configure(const config_param &, Error &)
+{
+ return true;
+}
+
+bool
+UpnpDatabase::Open(Error &error)
+{
+ if (!UpnpClientGlobalInit(handle, error))
+ return false;
+
+ discovery = new UPnPDeviceDirectory(handle);
+ if (!discovery->Start(error)) {
+ delete discovery;
+ UpnpClientGlobalFinish();
+ return false;
+ }
+
+ return true;
+}
+
+void
+UpnpDatabase::Close()
+{
+ delete discovery;
+ UpnpClientGlobalFinish();
+}
+
+void
+UpnpDatabase::ReturnSong(const LightSong *_song) const
+{
+ assert(_song != nullptr);
+
+ UpnpSong *song = (UpnpSong *)const_cast<LightSong *>(_song);
+ delete song;
+}
+
+// Get song info by path. We can receive either the id path, or the titles
+// one
+const LightSong *
+UpnpDatabase::GetSong(const char *uri, Error &error) const
+{
+ auto vpath = stringToTokens(uri, "/", true);
+ if (vpath.size() < 2) {
+ error.Format(db_domain, DB_NOT_FOUND, "No such song: %s", uri);
+ return nullptr;
+ }
+
+ ContentDirectoryService server;
+ if (!discovery->getServer(vpath.front().c_str(), server, error))
+ return nullptr;
+
+ vpath.pop_front();
+
+ UPnPDirObject dirent;
+ if (vpath.front() != rootid) {
+ if (!Namei(server, vpath, dirent, error))
+ return nullptr;
+ } else {
+ if (!ReadNode(server, vpath.back().c_str(), dirent,
+ error))
+ return nullptr;
+ }
+
+ return new UpnpSong(std::move(dirent), uri);
+}
+
+/**
+ * Double-quote a string, adding internal backslash escaping.
+ */
+static void
+dquote(std::string &out, const char *in)
+{
+ out.push_back('"');
+
+ for (; *in != 0; ++in) {
+ switch(*in) {
+ case '\\':
+ case '"':
+ out.push_back('\\');
+ break;
+ }
+
+ out.push_back(*in);
+ }
+
+ out.push_back('"');
+}
+
+// Run an UPnP search, according to MPD parameters. Return results as
+// UPnP items
+bool
+UpnpDatabase::SearchSongs(const ContentDirectoryService &server,
+ const char *objid,
+ const DatabaseSelection &selection,
+ UPnPDirContent &dirbuf,
+ Error &error) const
+{
+ const SongFilter *filter = selection.filter;
+ if (selection.filter == nullptr)
+ return true;
+
+ std::list<std::string> searchcaps;
+ if (!server.getSearchCapabilities(handle, searchcaps, error))
+ return false;
+
+ if (searchcaps.empty())
+ return true;
+
+ std::string cond;
+ for (const auto &item : filter->GetItems()) {
+ switch (auto tag = item.GetTag()) {
+ case LOCATE_TAG_ANY_TYPE:
+ {
+ if (!cond.empty()) {
+ cond += " and ";
+ }
+ cond += '(';
+ bool first(true);
+ for (const auto& cap : searchcaps) {
+ if (first)
+ first = false;
+ else
+ cond += " or ";
+ cond += cap;
+ if (item.GetFoldCase()) {
+ cond += " contains ";
+ } else {
+ cond += " = ";
+ }
+ dquote(cond, item.GetValue().c_str());
+ }
+ cond += ')';
+ }
+ break;
+
+ default:
+ /* Unhandled conditions like
+ LOCATE_TAG_BASE_TYPE or
+ LOCATE_TAG_FILE_TYPE won't have a
+ corresponding upnp prop, so they will be
+ skipped */
+ if (tag == TAG_ALBUM_ARTIST)
+ tag = TAG_ARTIST;
+
+ // TODO: support LOCATE_TAG_ANY_TYPE etc.
+ const char *name = tag_table_lookup(upnp_tags,
+ TagType(tag));
+ if (name == nullptr)
+ continue;
+
+ if (!cond.empty()) {
+ cond += " and ";
+ }
+ cond += name;
+
+ /* FoldCase doubles up as contains/equal
+ switch. UpNP search is supposed to be
+ case-insensitive, but at least some servers
+ have the same convention as mpd (e.g.:
+ minidlna) */
+ if (item.GetFoldCase()) {
+ cond += " contains ";
+ } else {
+ cond += " = ";
+ }
+ dquote(cond, item.GetValue().c_str());
+ }
+ }
+
+ return server.search(handle,
+ objid, cond.c_str(), dirbuf,
+ error);
+}
+
+static bool
+visitSong(const UPnPDirObject &meta, const char *path,
+ const DatabaseSelection &selection,
+ VisitSong visit_song, Error& error)
+{
+ if (!visit_song)
+ return true;
+
+ LightSong song;
+ song.directory = nullptr;
+ song.uri = path;
+ song.real_uri = meta.url.c_str();
+ song.tag = &meta.tag;
+ song.mtime = 0;
+ song.start_ms = song.end_ms = 0;
+
+ return !selection.Match(song) || visit_song(song, error);
+}
+
+/**
+ * Build synthetic path based on object id for search results. The use
+ * of "rootid" is arbitrary, any name that is not likely to be a top
+ * directory name would fit.
+ */
+static std::string
+songPath(const std::string &servername,
+ const std::string &objid)
+{
+ return servername + "/" + rootid + "/" + objid;
+}
+
+bool
+UpnpDatabase::SearchSongs(const ContentDirectoryService &server,
+ const char *objid,
+ const DatabaseSelection &selection,
+ VisitSong visit_song,
+ Error &error) const
+{
+ UPnPDirContent dirbuf;
+ if (!visit_song)
+ return true;
+ if (!SearchSongs(server, objid, selection, dirbuf, error))
+ return false;
+
+ for (auto &dirent : dirbuf.objects) {
+ if (dirent.type != UPnPDirObject::Type::ITEM ||
+ dirent.item_class != UPnPDirObject::ItemClass::MUSIC)
+ continue;
+
+ // We get song ids as the result of the UPnP search. But our
+ // client expects paths (e.g. we get 1$4$3788 from minidlna,
+ // but we need to translate to /Music/All_Music/Satisfaction).
+ // We can do this in two ways:
+ // - Rebuild a normal path using BuildPath() which is a kind of pwd
+ // - Build a bogus path based on the song id.
+ // The first method is nice because the returned paths are pretty, but
+ // it has two big problems:
+ // - The song paths are ambiguous: e.g. minidlna returns all search
+ // results as being from the "All Music" directory, which can
+ // contain several songs with the same title (but different objids)
+ // - The performance of BuildPath() is atrocious on very big
+ // directories, even causing timeouts in clients. And of
+ // course, 'All Music' is very big.
+ // So we return synthetic and ugly paths based on the object id,
+ // which we later have to detect.
+ const std::string path = songPath(server.getFriendlyName(),
+ dirent.m_id);
+ if (!visitSong(std::move(dirent), path.c_str(),
+ selection, visit_song,
+ error))
+ return false;
+ }
+
+ return true;
+}
+
+bool
+UpnpDatabase::ReadNode(const ContentDirectoryService &server,
+ const char *objid, UPnPDirObject &dirent,
+ Error &error) const
+{
+ UPnPDirContent dirbuf;
+ if (!server.getMetadata(handle, objid, dirbuf, error))
+ return false;
+
+ if (dirbuf.objects.size() == 1) {
+ dirent = std::move(dirbuf.objects.front());
+ } else {
+ error.Format(upnp_domain, "Bad resource");
+ return false;
+ }
+
+ return true;
+}
+
+bool
+UpnpDatabase::BuildPath(const ContentDirectoryService &server,
+ const UPnPDirObject& idirent,
+ std::string &path,
+ Error &error) const
+{
+ const char *pid = idirent.m_id.c_str();
+ path.clear();
+ UPnPDirObject dirent;
+ while (strcmp(pid, rootid) != 0) {
+ if (!ReadNode(server, pid, dirent, error))
+ return false;
+ pid = dirent.m_pid.c_str();
+
+ if (path.empty())
+ path = dirent.name;
+ else
+ path = PathTraitsUTF8::Build(dirent.name.c_str(),
+ path.c_str());
+ }
+
+ path = PathTraitsUTF8::Build(server.getFriendlyName(),
+ path.c_str());
+ return true;
+}
+
+// Take server and internal title pathname and return objid and metadata.
+bool
+UpnpDatabase::Namei(const ContentDirectoryService &server,
+ const std::list<std::string> &vpath,
+ UPnPDirObject &odirent,
+ Error &error) const
+{
+ if (vpath.empty()) {
+ // looking for root info
+ if (!ReadNode(server, rootid, odirent, error))
+ return false;
+
+ return true;
+ }
+
+ std::string objid(rootid);
+
+ // Walk the path elements, read each directory and try to find the next one
+ for (auto i = vpath.begin(), last = std::prev(vpath.end());; ++i) {
+ UPnPDirContent dirbuf;
+ if (!server.readDir(handle, objid.c_str(), dirbuf, error))
+ return false;
+
+ // Look for the name in the sub-container list
+ UPnPDirObject *child = dirbuf.FindObject(i->c_str());
+ if (child == nullptr) {
+ error.Format(db_domain, DB_NOT_FOUND,
+ "No such object");
+ return false;
+ }
+
+ if (i == last) {
+ odirent = std::move(*child);
+ return true;
+ }
+
+ if (child->type != UPnPDirObject::Type::CONTAINER) {
+ error.Format(db_domain, DB_NOT_FOUND,
+ "Not a container");
+ return false;
+ }
+
+ objid = std::move(child->m_id);
+ }
+}
+
+static bool
+VisitItem(const UPnPDirObject &object, const char *uri,
+ const DatabaseSelection &selection,
+ VisitSong visit_song, VisitPlaylist visit_playlist,
+ Error &error)
+{
+ assert(object.type == UPnPDirObject::Type::ITEM);
+
+ switch (object.item_class) {
+ case UPnPDirObject::ItemClass::MUSIC:
+ return !visit_song ||
+ visitSong(object, uri,
+ selection, visit_song, error);
+
+ case UPnPDirObject::ItemClass::PLAYLIST:
+ if (visit_playlist) {
+ /* Note: I've yet to see a
+ playlist item (playlists
+ seem to be usually handled
+ as containers, so I'll
+ decide what to do when I
+ see one... */
+ }
+
+ return true;
+
+ case UPnPDirObject::ItemClass::UNKNOWN:
+ return true;
+ }
+
+ assert(false);
+ gcc_unreachable();
+}
+
+static bool
+VisitObject(const UPnPDirObject &object, const char *uri,
+ const DatabaseSelection &selection,
+ VisitDirectory visit_directory,
+ VisitSong visit_song,
+ VisitPlaylist visit_playlist,
+ Error &error)
+{
+ switch (object.type) {
+ case UPnPDirObject::Type::UNKNOWN:
+ assert(false);
+ gcc_unreachable();
+
+ case UPnPDirObject::Type::CONTAINER:
+ return !visit_directory ||
+ visit_directory(LightDirectory(uri, 0), error);
+
+ case UPnPDirObject::Type::ITEM:
+ return VisitItem(object, uri, selection,
+ visit_song, visit_playlist,
+ error);
+ }
+
+ assert(false);
+ gcc_unreachable();
+}
+
+// vpath is a parsed and writeable version of selection.uri. There is
+// really just one path parameter.
+bool
+UpnpDatabase::VisitServer(const ContentDirectoryService &server,
+ const std::list<std::string> &vpath,
+ const DatabaseSelection &selection,
+ VisitDirectory visit_directory,
+ VisitSong visit_song,
+ VisitPlaylist visit_playlist,
+ Error &error) const
+{
+ /* If the path begins with rootid, we know that this is a
+ song, not a directory (because that's how we set things
+ up). Just visit it. Note that the choice of rootid is
+ arbitrary, any value not likely to be the name of a top
+ directory would be ok. */
+ /* !Note: this *can't* be handled by Namei further down,
+ because the path is not valid for traversal. Besides, it's
+ just faster to access the target node directly */
+ if (!vpath.empty() && vpath.front() == rootid) {
+ switch (vpath.size()) {
+ case 1:
+ return true;
+
+ case 2:
+ break;
+
+ default:
+ error.Format(db_domain, DB_NOT_FOUND,
+ "Not found");
+ return false;
+ }
+
+ if (visit_song) {
+ UPnPDirObject dirent;
+ if (!ReadNode(server, vpath.back().c_str(), dirent,
+ error))
+ return false;
+
+ if (dirent.type != UPnPDirObject::Type::ITEM ||
+ dirent.item_class != UPnPDirObject::ItemClass::MUSIC) {
+ error.Format(db_domain, DB_NOT_FOUND,
+ "Not found");
+ return false;
+ }
+
+ std::string path = songPath(server.getFriendlyName(),
+ dirent.m_id);
+ if (!visitSong(std::move(dirent), path.c_str(),
+ selection,
+ visit_song, error))
+ return false;
+ }
+ return true;
+ }
+
+ // Translate the target path into an object id and the associated metadata.
+ UPnPDirObject tdirent;
+ if (!Namei(server, vpath, tdirent, error))
+ return false;
+
+ /* If recursive is set, this is a search... No use sending it
+ if the filter is empty. In this case, we implement limited
+ recursion (1-deep) here, which will handle the "add dir"
+ case. */
+ if (selection.recursive && selection.filter)
+ return SearchSongs(server, tdirent.m_id.c_str(), selection,
+ visit_song, error);
+
+ const char *const base_uri = selection.uri.empty()
+ ? server.getFriendlyName()
+ : selection.uri.c_str();
+
+ if (tdirent.type == UPnPDirObject::Type::ITEM) {
+ return VisitItem(tdirent, base_uri,
+ selection,
+ visit_song, visit_playlist,
+ error);
+ }
+
+ /* Target was a a container. Visit it. We could read slices
+ and loop here, but it's not useful as mpd will only return
+ data to the client when we're done anyway. */
+ UPnPDirContent dirbuf;
+ if (!server.readDir(handle, tdirent.m_id.c_str(), dirbuf,
+ error))
+ return false;
+
+ for (auto &dirent : dirbuf.objects) {
+ const std::string uri = PathTraitsUTF8::Build(base_uri,
+ dirent.name.c_str());
+ if (!VisitObject(dirent, uri.c_str(),
+ selection,
+ visit_directory,
+ visit_song, visit_playlist,
+ error))
+ return false;
+ }
+
+ return true;
+}
+
+// Deal with the possibly multiple servers, call VisitServer if needed.
+bool
+UpnpDatabase::Visit(const DatabaseSelection &selection,
+ VisitDirectory visit_directory,
+ VisitSong visit_song,
+ VisitPlaylist visit_playlist,
+ Error &error) const
+{
+ auto vpath = stringToTokens(selection.uri, "/", true);
+ if (vpath.empty()) {
+ std::vector<ContentDirectoryService> servers;
+ if (!discovery->getDirServices(servers, error))
+ return false;
+
+ for (const auto &server : servers) {
+ if (visit_directory) {
+ const LightDirectory d(server.getFriendlyName(), 0);
+ if (!visit_directory(d, error))
+ return false;
+ }
+
+ if (selection.recursive &&
+ !VisitServer(server, vpath, selection,
+ visit_directory, visit_song, visit_playlist,
+ error))
+ return false;
+ }
+
+ return true;
+ }
+
+ // We do have a path: the first element selects the server
+ std::string servername(std::move(vpath.front()));
+ vpath.pop_front();
+
+ ContentDirectoryService server;
+ if (!discovery->getServer(servername.c_str(), server, error))
+ return false;
+
+ return VisitServer(server, vpath, selection,
+ visit_directory, visit_song, visit_playlist, error);
+}
+
+bool
+UpnpDatabase::VisitUniqueTags(const DatabaseSelection &selection,
+ TagType tag, gcc_unused uint32_t group_mask,
+ VisitTag visit_tag,
+ Error &error) const
+{
+ // TODO: use group_mask
+
+ if (!visit_tag)
+ return true;
+
+ std::vector<ContentDirectoryService> servers;
+ if (!discovery->getDirServices(servers, error))
+ return false;
+
+ std::set<std::string> values;
+ for (auto& server : servers) {
+ UPnPDirContent dirbuf;
+ if (!SearchSongs(server, rootid, selection, dirbuf, error))
+ return false;
+
+ for (const auto &dirent : dirbuf.objects) {
+ if (dirent.type != UPnPDirObject::Type::ITEM ||
+ dirent.item_class != UPnPDirObject::ItemClass::MUSIC)
+ continue;
+
+ const char *value = dirent.tag.GetValue(tag);
+ if (value != nullptr) {
+#if defined(__clang__) || GCC_CHECK_VERSION(4,8)
+ values.emplace(value);
+#else
+ values.insert(value);
+#endif
+ }
+ }
+ }
+
+ for (const auto& value : values) {
+ TagBuilder builder;
+ builder.AddItem(tag, value.c_str());
+ if (!visit_tag(builder.Commit(), error))
+ return false;
+ }
+
+ return true;
+}
+
+bool
+UpnpDatabase::GetStats(const DatabaseSelection &,
+ DatabaseStats &stats, Error &) const
+{
+ /* Note: this gets called before the daemonizing so we can't
+ reallyopen this would be a problem if we had real stats */
+ stats.song_count = 0;
+ stats.total_duration = 0;
+ stats.artist_count = 0;
+ stats.album_count = 0;
+ return true;
+}
+
+const DatabasePlugin upnp_db_plugin = {
+ "upnp",
+ 0,
+ UpnpDatabase::Create,
+};
diff --git a/src/db/plugins/upnp/UpnpDatabasePlugin.hxx b/src/db/plugins/upnp/UpnpDatabasePlugin.hxx
new file mode 100644
index 000000000..0228405cd
--- /dev/null
+++ b/src/db/plugins/upnp/UpnpDatabasePlugin.hxx
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2003-2014 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU 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.
+ */
+
+#ifndef MPD_UPNP_DATABASE_PLUGIN_HXX
+#define MPD_UPNP_DATABASE_PLUGIN_HXX
+
+struct DatabasePlugin;
+
+extern const DatabasePlugin upnp_db_plugin;
+
+#endif