UpnpDatabasePlugin.cxx 19.6 KB
Newer Older
1
/*
Max Kellermann's avatar
Max Kellermann committed
2
 * Copyright (C) 2003-2014 The Music Player Daemon Project
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
 * 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"
22 23
#include "Directory.hxx"
#include "Tags.hxx"
24
#include "lib/upnp/Domain.hxx"
25
#include "lib/upnp/ClientInit.hxx"
26 27 28
#include "lib/upnp/Discovery.hxx"
#include "lib/upnp/ContentDirectoryService.hxx"
#include "lib/upnp/Util.hxx"
29
#include "db/Interface.hxx"
Max Kellermann's avatar
Max Kellermann committed
30 31 32 33 34
#include "db/DatabasePlugin.hxx"
#include "db/Selection.hxx"
#include "db/DatabaseError.hxx"
#include "db/LightDirectory.hxx"
#include "db/LightSong.hxx"
35
#include "db/Stats.hxx"
36
#include "config/ConfigData.hxx"
37 38 39 40
#include "tag/TagBuilder.hxx"
#include "tag/TagTable.hxx"
#include "util/Error.hxx"
#include "util/Domain.hxx"
41
#include "fs/Traits.hxx"
42 43 44 45 46 47 48 49 50 51 52 53
#include "Log.hxx"
#include "SongFilter.hxx"

#include <string>
#include <vector>
#include <set>

#include <assert.h>
#include <string.h>

static const char *const rootid = "0";

54
class UpnpSong : public LightSong {
55
	std::string uri2, real_uri2;
56 57 58 59

	Tag tag2;

public:
60 61 62
	UpnpSong(UPnPDirObject &&object, std::string &&_uri)
		:uri2(std::move(_uri)),
		 real_uri2(std::move(object.url)),
63
		 tag2(std::move(object.tag)) {
64 65
		directory = nullptr;
		uri = uri2.c_str();
66
		real_uri = real_uri2.c_str();
67 68 69 70 71 72
		tag = &tag2;
		mtime = 0;
		start_ms = end_ms = 0;
	}
};

73
class UpnpDatabase : public Database {
74
	UpnpClient_Handle handle;
75
	UPnPDeviceDirectory *discovery;
76 77

public:
78 79
	UpnpDatabase():Database(upnp_db_plugin) {}

80 81
	static Database *Create(EventLoop &loop, DatabaseListener &listener,
				const config_param &param,
82 83 84 85
				Error &error);

	virtual bool Open(Error &error) override;
	virtual void Close() override;
86 87 88
	virtual const LightSong *GetSong(const char *uri_utf8,
					 Error &error) const override;
	virtual void ReturnSong(const LightSong *song) const;
89 90 91 92 93 94 95 96

	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,
97 98
				     TagType tag_type, uint32_t group_mask,
				     VisitTag visit_tag,
99 100 101 102 103 104 105 106 107 108 109
				     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:
110
	bool VisitServer(const ContentDirectoryService &server,
111
			 const std::list<std::string> &vpath,
112 113 114 115 116 117 118 119 120 121
			 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.
	 */
122
	bool SearchSongs(const ContentDirectoryService &server,
123 124 125 126 127
			 const char *objid,
			 const DatabaseSelection &selection,
			 VisitSong visit_song,
			 Error &error) const;

128
	bool SearchSongs(const ContentDirectoryService &server,
129 130 131 132 133
			 const char *objid,
			 const DatabaseSelection &selection,
			 UPnPDirContent& dirbuf,
			 Error &error) const;

134
	bool Namei(const ContentDirectoryService &server,
135
		   const std::list<std::string> &vpath,
136
		   UPnPDirObject &dirent,
137 138 139 140 141
		   Error &error) const;

	/**
	 * Take server and objid, return metadata.
	 */
142
	bool ReadNode(const ContentDirectoryService &server,
143 144 145 146 147 148 149 150
		      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).
	 */
151
	bool BuildPath(const ContentDirectoryService &server,
152 153 154 155 156
		       const UPnPDirObject& dirent, std::string &idpath,
		       Error &error) const;
};

Database *
157 158 159
UpnpDatabase::Create(gcc_unused EventLoop &loop,
		     gcc_unused DatabaseListener &listener,
		     const config_param &param, Error &error)
160 161
{
	UpnpDatabase *db = new UpnpDatabase();
162
	if (!db->Configure(param, error)) {
163
		delete db;
164
		return nullptr;
165
	}
166 167 168 169

	/* libupnp loses its ability to receive multicast messages
	   apparently due to daemonization; using the LazyDatabase
	   wrapper works around this problem */
170
	return db;
171 172
}

173
inline bool
174
UpnpDatabase::Configure(const config_param &, Error &)
175 176 177 178 179
{
	return true;
}

bool
180
UpnpDatabase::Open(Error &error)
181
{
182
	if (!UpnpClientGlobalInit(handle, error))
183 184
		return false;

185
	discovery = new UPnPDeviceDirectory(handle);
186 187
	if (!discovery->Start(error)) {
		delete discovery;
188
		UpnpClientGlobalFinish();
189 190 191 192 193 194 195 196 197
		return false;
	}

	return true;
}

void
UpnpDatabase::Close()
{
198
	delete discovery;
199
	UpnpClientGlobalFinish();
200 201 202
}

void
203
UpnpDatabase::ReturnSong(const LightSong *_song) const
204
{
205
	assert(_song != nullptr);
206

207 208
	UpnpSong *song = (UpnpSong *)const_cast<LightSong *>(_song);
	delete song;
209 210 211 212
}

// Get song info by path. We can receive either the id path, or the titles
// one
213
const LightSong *
214 215 216
UpnpDatabase::GetSong(const char *uri, Error &error) const
{
	auto vpath = stringToTokens(uri, "/", true);
217 218 219 220
	if (vpath.size() < 2) {
		error.Format(db_domain, DB_NOT_FOUND, "No such song: %s", uri);
		return nullptr;
	}
221

222
	ContentDirectoryService server;
223
	if (!discovery->getServer(vpath.front().c_str(), server, error))
224
		return nullptr;
225

226 227
	vpath.pop_front();

228
	UPnPDirObject dirent;
229
	if (vpath.front() != rootid) {
230
		if (!Namei(server, vpath, dirent, error))
231 232 233 234 235
			return nullptr;
	} else {
		if (!ReadNode(server, vpath.back().c_str(), dirent,
			      error))
			return nullptr;
236 237
	}

238
	return new UpnpSong(std::move(dirent), uri);
239 240 241 242 243 244 245 246
}

/**
 * Double-quote a string, adding internal backslash escaping.
 */
static void
dquote(std::string &out, const char *in)
{
247
	out.push_back('"');
248 249 250 251 252

	for (; *in != 0; ++in) {
		switch(*in) {
		case '\\':
		case '"':
253
			out.push_back('\\');
254 255
			break;
		}
256 257

		out.push_back(*in);
258 259
	}

260
	out.push_back('"');
261 262 263 264 265
}

// Run an UPnP search, according to MPD parameters. Return results as
// UPnP items
bool
266
UpnpDatabase::SearchSongs(const ContentDirectoryService &server,
267 268 269 270 271 272 273 274 275
			  const char *objid,
			  const DatabaseSelection &selection,
			  UPnPDirContent &dirbuf,
			  Error &error) const
{
	const SongFilter *filter = selection.filter;
	if (selection.filter == nullptr)
		return true;

276
	std::list<std::string> searchcaps;
277
	if (!server.getSearchCapabilities(handle, searchcaps, error))
278 279 280 281 282 283 284 285 286 287 288 289 290
		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 ";
				}
291
				cond += '(';
292 293 294 295 296 297 298 299 300 301 302 303 304 305
				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());
				}
306
				cond += ')';
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343
			}
			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());
		}
	}

344
	return server.search(handle,
345 346
			     objid, cond.c_str(), dirbuf,
			     error);
347 348 349
}

static bool
350
visitSong(const UPnPDirObject &meta, const char *path,
351 352 353 354 355
	  const DatabaseSelection &selection,
	  VisitSong visit_song, Error& error)
{
	if (!visit_song)
		return true;
356

357 358
	LightSong song;
	song.directory = nullptr;
359
	song.uri = path;
360 361 362 363 364
	song.real_uri = meta.url.c_str();
	song.tag = &meta.tag;
	song.mtime = 0;
	song.start_ms = song.end_ms = 0;

365
	return !selection.Match(song) || visit_song(song, error);
366 367 368 369 370 371 372
}

/**
 * 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.
 */
373
static std::string
374 375 376 377 378 379 380
songPath(const std::string &servername,
	 const std::string &objid)
{
	return servername + "/" + rootid + "/" + objid;
}

bool
381
UpnpDatabase::SearchSongs(const ContentDirectoryService &server,
382 383 384 385 386 387 388 389 390 391 392
			  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;

393
	for (auto &dirent : dirbuf.objects) {
394 395 396 397
		if (dirent.type != UPnPDirObject::Type::ITEM ||
		    dirent.item_class != UPnPDirObject::ItemClass::MUSIC)
			continue;

398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
		// 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.
414 415 416
		const std::string path = songPath(server.getFriendlyName(),
						  dirent.m_id);
		if (!visitSong(std::move(dirent), path.c_str(),
417
			       selection, visit_song,
418 419 420 421 422 423 424 425
			       error))
			return false;
	}

	return true;
}

bool
426
UpnpDatabase::ReadNode(const ContentDirectoryService &server,
427 428 429 430
		       const char *objid, UPnPDirObject &dirent,
		       Error &error) const
{
	UPnPDirContent dirbuf;
431
	if (!server.getMetadata(handle, objid, dirbuf, error))
432 433
		return false;

434
	if (dirbuf.objects.size() == 1) {
435
		dirent = std::move(dirbuf.objects.front());
436 437 438 439 440 441 442 443 444
	} else {
		error.Format(upnp_domain, "Bad resource");
		return false;
	}

	return true;
}

bool
445
UpnpDatabase::BuildPath(const ContentDirectoryService &server,
446 447 448 449 450 451 452 453 454 455 456
			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();
457 458 459 460 461 462

		if (path.empty())
			path = dirent.name;
		else
			path = PathTraitsUTF8::Build(dirent.name.c_str(),
						     path.c_str());
463
	}
464 465 466

	path = PathTraitsUTF8::Build(server.getFriendlyName(),
				     path.c_str());
467 468 469 470 471
	return true;
}

// Take server and internal title pathname and return objid and metadata.
bool
472
UpnpDatabase::Namei(const ContentDirectoryService &server,
473
		    const std::list<std::string> &vpath,
474
		    UPnPDirObject &odirent,
475 476 477 478 479 480 481 482 483 484
		    Error &error) const
{
	if (vpath.empty()) {
		// looking for root info
		if (!ReadNode(server, rootid, odirent, error))
			return false;

		return true;
	}

485 486
	std::string objid(rootid);

487
	// Walk the path elements, read each directory and try to find the next one
488
	for (auto i = vpath.begin(), last = std::prev(vpath.end());; ++i) {
489
		UPnPDirContent dirbuf;
490
		if (!server.readDir(handle, objid.c_str(), dirbuf, error))
491 492 493
			return false;

		// Look for the name in the sub-container list
494
		UPnPDirObject *child = dirbuf.FindObject(i->c_str());
495 496 497 498 499
		if (child == nullptr) {
			error.Format(db_domain, DB_NOT_FOUND,
				     "No such object");
			return false;
		}
500

501 502 503 504
		if (i == last) {
			odirent = std::move(*child);
			return true;
		}
505

506 507 508 509
		if (child->type != UPnPDirObject::Type::CONTAINER) {
			error.Format(db_domain, DB_NOT_FOUND,
				     "Not a container");
			return false;
510
		}
511 512

		objid = std::move(child->m_id);
513 514 515
	}
}

516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549
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();
}

550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576
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();
}

577 578 579
// vpath is a parsed and writeable version of selection.uri. There is
// really just one path parameter.
bool
580
UpnpDatabase::VisitServer(const ContentDirectoryService &server,
581
			  const std::list<std::string> &vpath,
582 583 584 585 586 587 588 589 590 591 592 593 594 595
			  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 */
596
	if (!vpath.empty() && vpath.front() == rootid) {
597 598 599 600 601 602 603 604 605 606 607 608 609
		switch (vpath.size()) {
		case 1:
			return true;

		case 2:
			break;

		default:
			error.Format(db_domain, DB_NOT_FOUND,
				     "Not found");
			return false;
		}

610 611 612 613 614 615
		if (visit_song) {
			UPnPDirObject dirent;
			if (!ReadNode(server, vpath.back().c_str(), dirent,
				      error))
				return false;

616 617 618 619 620 621 622
			if (dirent.type != UPnPDirObject::Type::ITEM ||
			    dirent.item_class != UPnPDirObject::ItemClass::MUSIC) {
				error.Format(db_domain, DB_NOT_FOUND,
					     "Not found");
				return false;
			}

623 624 625 626
			std::string path = songPath(server.getFriendlyName(),
						    dirent.m_id);
			if (!visitSong(std::move(dirent), path.c_str(),
				       selection,
627 628 629 630 631 632 633 634
				       visit_song, error))
				return false;
		}
		return true;
	}

	// Translate the target path into an object id and the associated metadata.
	UPnPDirObject tdirent;
635
	if (!Namei(server, vpath, tdirent, error))
636 637 638 639 640 641 642
		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)
643
		return SearchSongs(server, tdirent.m_id.c_str(), selection,
644 645
				   visit_song, error);

Max Kellermann's avatar
Max Kellermann committed
646 647 648 649
	const char *const base_uri = selection.uri.empty()
		? server.getFriendlyName()
		: selection.uri.c_str();

650
	if (tdirent.type == UPnPDirObject::Type::ITEM) {
651 652 653 654
		return VisitItem(tdirent, base_uri,
				 selection,
				 visit_song, visit_playlist,
				 error);
655 656 657 658 659 660
	}

	/* 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;
661
	if (!server.readDir(handle, tdirent.m_id.c_str(), dirbuf,
662
			    error))
663 664
		return false;

665
	for (auto &dirent : dirbuf.objects) {
666 667
		const std::string uri = PathTraitsUTF8::Build(base_uri,
							      dirent.name.c_str());
668 669 670 671 672 673
		if (!VisitObject(dirent, uri.c_str(),
				 selection,
				 visit_directory,
				 visit_song, visit_playlist,
				 error))
			return false;
674 675 676 677 678 679 680 681 682 683 684 685 686 687 688
	}

	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()) {
689
		std::vector<ContentDirectoryService> servers;
690
		if (!discovery->getDirServices(servers, error))
691 692
			return false;

693
		for (const auto &server : servers) {
694
			if (visit_directory) {
695 696
				const LightDirectory d(server.getFriendlyName(), 0);
				if (!visit_directory(d, error))
697 698
					return false;
			}
699 700 701 702 703 704

			if (selection.recursive &&
			    !VisitServer(server, vpath, selection,
					 visit_directory, visit_song, visit_playlist,
					 error))
				return false;
705
		}
706

707 708 709 710
		return true;
	}

	// We do have a path: the first element selects the server
711
	std::string servername(std::move(vpath.front()));
712
	vpath.pop_front();
713

714
	ContentDirectoryService server;
715
	if (!discovery->getServer(servername.c_str(), server, error))
716 717 718
		return false;

	return VisitServer(server, vpath, selection,
719 720 721 722 723
			   visit_directory, visit_song, visit_playlist, error);
}

bool
UpnpDatabase::VisitUniqueTags(const DatabaseSelection &selection,
724 725
			      TagType tag, gcc_unused uint32_t group_mask,
			      VisitTag visit_tag,
726 727
			      Error &error) const
{
728 729 730
	// TODO: use group_mask

	if (!visit_tag)
731 732 733
		return true;

	std::vector<ContentDirectoryService> servers;
734
	if (!discovery->getDirServices(servers, error))
735 736 737 738 739
		return false;

	std::set<std::string> values;
	for (auto& server : servers) {
		UPnPDirContent dirbuf;
740
		if (!SearchSongs(server, rootid, selection, dirbuf, error))
741 742
			return false;

743 744 745 746 747
		for (const auto &dirent : dirbuf.objects) {
			if (dirent.type != UPnPDirObject::Type::ITEM ||
			    dirent.item_class != UPnPDirObject::ItemClass::MUSIC)
				continue;

748
			const char *value = dirent.tag.GetValue(tag);
749
			if (value != nullptr) {
750
#if defined(__clang__) || GCC_CHECK_VERSION(4,8)
751
				values.emplace(value);
752
#else
753
				values.insert(value);
754 755
#endif
			}
756 757 758
		}
	}

759 760 761 762
	for (const auto& value : values) {
		TagBuilder builder;
		builder.AddItem(tag, value.c_str());
		if (!visit_tag(builder.Commit(), error))
763
			return false;
764
	}
765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783

	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",
784
	0,
785 786
	UpnpDatabase::Create,
};