diff --git a/configure.ac b/configure.ac
index b5b8652..c2a7dfe 100644
--- a/configure.ac
+++ b/configure.ac
@@ -167,6 +177,18 @@ AS_IF([test "x$with_media_engine" = "xgstreamer"],
             AC_SUBST([RYGEL_PLUGIN_MEDIA_EXPORT_DEPS_VALAFLAGS])
           ])
 
+        RYGEL_ADD_PLUGIN([odid],[ODID],[yes])
+        AS_IF([test "x$enable_odid_plugin" = "xyes"],
+          [
+            PKG_CHECK_MODULES([RYGEL_PLUGIN_ODID_DEPS],
+                              [$RYGEL_COMMON_MODULES gio-2.0 >= $GIO_REQUIRED
+                               libsoup-2.4 >= $LIBSOUP_REQUIRED
+                               sqlite3 >= $LIBSQLITE3_REQUIRED
+                               uuid])
+            RYGEL_PLUGIN_ODID_DEPS_VALAFLAGS="$RYGEL_COMMON_MODULES_VALAFLAGS --pkg sqlite3"
+            AC_SUBST([RYGEL_PLUGIN_ODID_DEPS_VALAFLAGS])
+          ])
+
         RYGEL_ADD_PLUGIN([gst-launch],[GStreamer launchline],[no])
         AS_IF([test "x$enable_gst_launch_plugin" = "xyes"],
           [
@@ -302,6 +325,19 @@ then
     fi
 fi
 
+dnl Check additional requirements for ODID plugin
+if test "x$enable_odid_plugin" = "xyes";
+then
+    RYGEL_CHECK_PACKAGES([sqlite3 gstreamer-tag-1.0 gstreamer-app-1.0])
+    AC_CHECK_HEADER([unistr.h],
+                    AC_CHECK_LIB([unistring],
+                                 [u8_strcoll],
+                                 [have_unistring=yes],[have_unistring=no]))
+    if test "x$have_unistring" = "xyes"; then
+        AC_DEFINE([HAVE_UNISTRING],[1],[Use libunistring for collation])
+        RYGEL_PLUGIN_ODID_DEPS_LIBS="$RYGEL_PLUGIN_ODID_DEPS_LIBS -lunistring"
+    fi
+fi
 
 RYGEL_ADD_PLUGIN([tracker],[Tracker],[yes])
 AS_IF([test "x$enable_tracker_plugin" = "xyes"],
@@ -472,6 +513,7 @@ echo "
             version:            ${tracker_api_version}
         mediathek:              ${enable_mediathek_plugin}
         media-export            ${enable_media_export_plugin}
+        odid:                   ${enable_odid_plugin}
         external:               ${enable_external_plugin}
         MPRIS2:                 ${enable_mpris_plugin}
         gst-launch:             ${enable_gst_launch_plugin}
diff --git a/data/rygel.conf b/data/rygel.conf
index 307da12..9f1c942 100644
--- a/data/rygel.conf
+++ b/data/rygel.conf
@@ -113,6 +138,18 @@ extract-metadata=true
 monitor-changes=true
 virtual-folders=true
 
+[ODIDMediaServer]
+enabled=true
+title=CVP2 DMS
+# List of URIs to export. Following variables are automatically substituted by
+# the appropriate XDG standard media folders by Rygel for you.
+#
+#       * @VIDEOS@: The standard videos folder (typically ${HOME}/Videos).
+#
+# Since we are disabling non av-upload disabling music and audio folders from harvesting.
+uris=@VIDEOS@
+monitor-changes=true
+
 [Playbin]
 enabled=true
 title=Audio/Video playback on @HOSTNAME@
diff --git a/src/plugins/Makefile.am b/src/plugins/Makefile.am
index 3c1365c..0a4072e 100644
--- a/src/plugins/Makefile.am
+++ b/src/plugins/Makefile.am
@@ -10,6 +10,10 @@ if BUILD_MEDIA_EXPORT_PLUGIN
 MEDIA_EXPORT_PLUGIN = media-export
 endif
 
+if BUILD_ODID_PLUGIN
+ODID_PLUGIN = odid
+endif
+
 if BUILD_EXTERNAL_PLUGIN
 EXTERNAL_PLUGIN = external
 endif
@@ -29,6 +33,7 @@ endif
 SUBDIRS = $(TRACKER_PLUGIN) \
 	  $(MEDIATHEK_PLUGIN) \
 	  $(MEDIA_EXPORT_PLUGIN) \
+	  $(ODID_PLUGIN) \
 	  $(EXTERNAL_PLUGIN) \
 	  $(MPRIS_PLUGIN) \
 	  $(GST_LAUNCH_PLUGIN) \
diff --git a/src/plugins/odid/Makefile.am b/src/plugins/odid/Makefile.am
new file mode 100644
index 0000000..ad358ee
--- /dev/null
+++ b/src/plugins/odid/Makefile.am
@@ -0,0 +1,54 @@
+include $(top_srcdir)/common.am
+
+plugin_LTLIBRARIES = librygel-odid.la
+plugin_DATA = odid.plugin
+
+librygel_odid_la_SOURCES = \
+	rygel-odid-plugin.vala \
+	rygel-odid-database.vala \
+	rygel-odid-database-cursor.vala \
+	rygel-odid-sqlite-wrapper.vala \
+	rygel-odid-db-container.vala \
+	rygel-odid-sql-factory.vala \
+	rygel-odid-media-cache.vala \
+	rygel-odid-sql-operator.vala \
+	rygel-odid-sql-function.vala \
+	rygel-odid-media-cache-upgrader.vala \
+	rygel-odid-null-container.vala \
+	rygel-odid-dummy-container.vala \
+	rygel-odid-root-container.vala \
+	rygel-odid-query-container.vala \
+	rygel-odid-query-container-factory.vala \
+	rygel-odid-node-query-container.vala \
+	rygel-odid-leaf-query-container.vala \
+	rygel-odid-recursive-file-monitor.vala \
+	rygel-odid-harvester.vala \
+	rygel-odid-harvesting-task.vala \
+	rygel-odid-object-factory.vala \
+	rygel-odid-writable-db-container.vala \
+	rygel-odid-media-item.vala \
+	rygel-odid-trackable-db-container.vala \
+	rygel-odid-updatable-object.vala \
+	rygel-odid-collate.c
+
+librygel_odid_la_VALAFLAGS = \
+	--enable-experimental \
+	--internal-vapi rygel-odid.vapi \
+	--internal-header rygel-odid-internal.h \
+	--header rygel-odid.h \
+	$(RYGEL_PLUGIN_ODID_DEPS_VALAFLAGS) \
+	$(RYGEL_COMMON_LIBRYGEL_SERVER_VALAFLAGS) \
+	$(RYGEL_COMMON_VALAFLAGS)
+
+librygel_odid_la_CFLAGS = \
+	$(RYGEL_PLUGIN_ODID_DEPS_CFLAGS) \
+	$(RYGEL_COMMON_LIBRYGEL_SERVER_CFLAGS) \
+	-DG_LOG_DOMAIN='"ODID"'
+librygel_odid_la_LIBADD = \
+	$(RYGEL_PLUGIN_ODID_DEPS_LIBS) \
+	$(RYGEL_COMMON_LIBRYGEL_SERVER_LIBS)
+
+librygel_odid_la_LDFLAGS = \
+	$(RYGEL_PLUGIN_LINKER_FLAGS)
+
+EXTRA_DIST = odid.plugin.in
diff --git a/src/plugins/odid/odid.plugin.in b/src/plugins/odid/odid.plugin.in
new file mode 100644
index 0000000..a7d3167
--- /dev/null
+++ b/src/plugins/odid/odid.plugin.in
@@ -0,0 +1,7 @@
+[Plugin]
+Version = @VERSION@
+Module = odid
+Name = ODIDMediaServer
+License = LGPL
+Description = SQLite-based locate file properties sharing
+Copyright = Copyright © Rygel maintainers
diff --git a/src/plugins/odid/rygel-odid-collate.c b/src/plugins/odid/rygel-odid-collate.c
new file mode 100644
index 0000000..779ee29
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-collate.c
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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 <glib.h>
+
+#ifdef HAVE_UNISTRING
+#include <unistr.h>
+gint rygel_odid_utf8_collate_str (const char *a, const char *b)
+{
+    return u8_strcoll (a, b);
+}
+
+#else
+
+gint rygel_odid_utf8_collate_str (const char *a, const char *b)
+{
+    return g_utf8_collate (a, b);
+}
+#endif
diff --git a/src/plugins/odid/rygel-odid-database-cursor.vala b/src/plugins/odid/rygel-odid-database-cursor.vala
new file mode 100644
index 0000000..7bc61dc
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-database-cursor.vala
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Sqlite;
+
+internal class Rygel.ODID.DatabaseCursor : SqliteWrapper {
+    private Statement statement;
+    private int current_state = -1;
+    private bool dirty = true;
+
+    /**
+     * Prepare a SQLite statement from a SQL string
+     *
+     * This function uses the type of the GValue passed in values to determine
+     * which _bind function to use.
+     *
+     * Supported types are: int, long, int64, uint64, string and pointer.
+     * @note the only pointer supported is the null pointer as provided by
+     * Database.@null. This is a special value to bind a column to NULL
+     *
+     * @param db SQLite database this cursor belongs to
+     * @param sql statement to execute
+     * @param values array of values to bind to the SQL statement or null if
+     * none
+     */
+    public DatabaseCursor (Sqlite.Database   db,
+                           string            sql,
+                           GLib.Value[]?     arguments) throws DatabaseError {
+        base.wrap (db);
+
+        this.throw_if_code_is_error (db.prepare_v2 (sql,
+                                                    -1,
+                                                    out this.statement,
+                                                    null));
+        if (arguments == null) {
+            return;
+        }
+
+        for (var i = 1; i <= arguments.length; ++i) {
+            unowned GLib.Value current_value = arguments[i - 1];
+
+            if (current_value.holds (typeof (int))) {
+                statement.bind_int (i, current_value.get_int ());
+            } else if (current_value.holds (typeof (int64))) {
+                statement.bind_int64 (i, current_value.get_int64 ());
+            } else if (current_value.holds (typeof (uint64))) {
+                statement.bind_int64 (i, (int64) current_value.get_uint64 ());
+            } else if (current_value.holds (typeof (long))) {
+                statement.bind_int64 (i, current_value.get_long ());
+            } else if (current_value.holds (typeof (uint))) {
+                statement.bind_int64 (i, current_value.get_uint ());
+            } else if (current_value.holds (typeof (string))) {
+                statement.bind_text (i, current_value.get_string ());
+            } else if (current_value.holds (typeof (void *))) {
+                if (current_value.peek_pointer () == null) {
+                    statement.bind_null (i);
+                } else {
+                    assert_not_reached ();
+                }
+            } else {
+                var type = current_value.type ();
+                warning (_("Unsupported type %s"), type.name ());
+                assert_not_reached ();
+            }
+
+            this.throw_if_db_has_error ();
+        }
+    }
+
+    /**
+     * Check if the cursor has more rows left
+     *
+     * @return true if more rows left, false otherwise
+     */
+    public bool has_next () throws DatabaseError {
+        if (this.dirty) {
+            this.current_state = this.statement.step ();
+            this.dirty = false;
+        }
+
+        this.throw_if_code_is_error (this.current_state);
+
+        return this.current_state == Sqlite.ROW || this.current_state == -1;
+    }
+
+    /**
+     * Get the next row of this cursor.
+     *
+     * This function uses pointers instead of unowned because var doesn't work
+     * with unowned.
+     *
+     * @return a pointer to the current row
+     */
+    public Statement* next () throws DatabaseError {
+        this.has_next ();
+        this.throw_if_code_is_error (this.current_state);
+        this.dirty = true;
+
+        return this.statement;
+    }
+
+    // convenience functions for "foreach"
+
+    /**
+     * Return an iterator to the cursor to use with foreach
+     *
+     * @return an iterator wrapping the cursor
+     */
+    public Iterator iterator () {
+        return new Iterator (this);
+    }
+
+    public class Iterator {
+        public DatabaseCursor cursor;
+
+        public Iterator (DatabaseCursor cursor) {
+            this.cursor = cursor;
+        }
+
+        public bool next () throws DatabaseError {
+            return this.cursor.has_next ();
+        }
+
+        public unowned Statement @get () throws DatabaseError {
+            return this.cursor.next ();
+        }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-database.vala b/src/plugins/odid/rygel-odid-database.vala
new file mode 100644
index 0000000..ad033dd
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-database.vala
@@ -0,0 +1,223 @@
+/*
+ * Copyright (C) 2009,2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Sqlite;
+
+public errordomain Rygel.ODID.DatabaseError {
+    IO_ERROR,
+    SQLITE_ERROR
+}
+
+namespace Rygel.ODID {
+    extern static int utf8_collate_str (string a, string b);
+}
+
+/**
+ * This class is a thin wrapper around SQLite's database object.
+ *
+ * It adds statement preparation based on GValue and a cancellable exec
+ * function.
+ */
+internal class Rygel.ODID.Database : SqliteWrapper {
+
+    /**
+     * Function to implement the custom SQL function 'contains'
+     */
+    private static void utf8_contains (Sqlite.Context context,
+                                       Sqlite.Value[] args)
+                                       requires (args.length == 2) {
+        if (args[1].to_text() == null) {
+           context.result_int (0);
+
+           return;
+        }
+
+        var pattern = Regex.escape_string (args[1].to_text ());
+        if (Regex.match_simple (pattern,
+                                args[0].to_text (),
+                                RegexCompileFlags.CASELESS)) {
+            context.result_int (1);
+        } else {
+            context.result_int (0);
+        }
+    }
+
+    /**
+     * Function to implement the custom SQLite collation 'CASEFOLD'.
+     *
+     * Uses utf8 case-fold to compare the strings.
+     */
+    private static int utf8_collate (int alen, void* a, int blen, void* b) {
+        // unowned to prevent array copy
+        unowned uint8[] _a = (uint8[]) a;
+        _a.length = alen;
+
+        unowned uint8[] _b = (uint8[]) b;
+        _b.length = blen;
+
+        var str_a = ((string) _a);
+        var str_b = ((string) _b);
+
+        return utf8_collate_str (str_a, str_b);
+    }
+
+    /**
+     * Open a database in the user's cache directory as defined by XDG
+     *
+     * @param name of the database, used to build full path
+     * (<cache-dir>/rygel/<name>.db)
+     */
+    public Database (string name) throws DatabaseError {
+        string db_file;
+
+        if (name != ":memory:") {
+            var dirname = Path.build_filename (
+                                        Environment.get_user_cache_dir (),
+                                        "rygel");
+            DirUtils.create_with_parents (dirname, 0750);
+            db_file = Path.build_filename (dirname, "%s.db".printf (name));
+        } else {
+            db_file = name;
+        }
+
+        base (db_file);
+
+        debug ("Using database file %s", db_file);
+
+        this.exec ("PRAGMA synchronous = OFF");
+        this.exec ("PRAGMA temp_store = MEMORY");
+        this.exec ("PRAGMA count_changes = OFF");
+
+        this.db.create_function ("contains",
+                                 2,
+                                 Sqlite.UTF8,
+                                 null,
+                                 Database.utf8_contains,
+                                 null,
+                                 null);
+
+        this.db.create_collation ("CASEFOLD",
+                                  Sqlite.UTF8,
+                                  Database.utf8_collate);
+    }
+
+    /**
+     * SQL query function.
+     *
+     * Use for all queries that return a result set.
+     *
+     * @param sql The SQL query to run.
+     * @param args Values to bind in the SQL query or null.
+     * @throws DatabaseError if the underlying SQLite operation fails.
+     */
+    public DatabaseCursor exec_cursor (string        sql,
+                                       GLib.Value[]? arguments = null)
+                                       throws DatabaseError {
+        return new DatabaseCursor (this.db, sql, arguments);
+    }
+
+    /**
+     * Simple SQL query execution function.
+     *
+     * Use for all queries that don't return anything.
+     *
+     * @param sql The SQL query to run.
+     * @param args Values to bind in the SQL query or null.
+     * @throws DatabaseError if the underlying SQLite operation fails.
+     */
+    public void exec (string        sql,
+                      GLib.Value[]? arguments = null)
+                      throws DatabaseError {
+        if (arguments == null) {
+            this.throw_if_code_is_error (this.db.exec (sql));
+
+            return;
+        }
+
+        var cursor = this.exec_cursor (sql, arguments);
+        while (cursor.has_next ()) {
+            cursor.next ();
+        }
+    }
+
+    /**
+     * Execute a SQL query that returns a single number.
+     *
+     * @param sql The SQL query to run.
+     * @param args Values to bind in the SQL query or null.
+     * @return The contents of the first row's column as an int.
+     * @throws DatabaseError if the underlying SQLite operation fails.
+     */
+    public int query_value (string        sql,
+                             GLib.Value[]? args = null)
+                             throws DatabaseError {
+        var cursor = this.exec_cursor (sql, args);
+        var statement = cursor.next ();
+        return statement->column_int (0);
+    }
+
+    /**
+     * Analyze triggers of database
+     */
+    public void analyze () {
+        this.db.exec ("ANALYZE");
+    }
+
+    /**
+     * Special GValue to pass to exec or exec_cursor to bind a column to
+     * NULL
+     */
+    public static GLib.Value @null () {
+        GLib.Value v = GLib.Value (typeof (void *));
+        v.set_pointer (null);
+
+        return v;
+    }
+
+    /**
+     * Start a transaction
+     */
+    public void begin () throws DatabaseError {
+        this.exec ("BEGIN");
+    }
+
+    /**
+     * Commit a transaction
+     */
+    public void commit () throws DatabaseError {
+        this.exec ("COMMIT");
+    }
+
+    /**
+     * Rollback a transaction
+     */
+    public void rollback () {
+        try {
+            this.exec ("ROLLBACK");
+        } catch (DatabaseError error) {
+            critical (_("Failed to roll back transaction: %s"),
+                      error.message);
+        }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-db-container.vala b/src/plugins/odid/rygel-odid-db-container.vala
new file mode 100644
index 0000000..7de4e28
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-db-container.vala
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2009 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using GUPnP;
+using Gee;
+
+public class Rygel.ODID.DBContainer : MediaContainer,
+                                             SearchableContainer {
+    protected MediaCache media_db;
+    public ArrayList<string> search_classes { get; set; }
+
+    public DBContainer (string id, string title) {
+        Object (id : id,
+                parent : null,
+                title : title,
+                child_count : 0);
+    }
+
+    public override void constructed () {
+        base.constructed ();
+
+        this.media_db = MediaCache.get_default ();
+        this.search_classes = new ArrayList<string> ();
+        // Items
+        this.search_classes.add (Rygel.ImageItem.UPNP_CLASS);
+        this.search_classes.add (Rygel.PhotoItem.UPNP_CLASS);
+        this.search_classes.add (Rygel.VideoItem.UPNP_CLASS);
+        this.search_classes.add (Rygel.AudioItem.UPNP_CLASS);
+        this.search_classes.add (Rygel.MusicItem.UPNP_CLASS);
+        this.search_classes.add (Rygel.PlaylistItem.UPNP_CLASS);
+
+        // Containers
+        this.search_classes.add (Rygel.MediaContainer.UPNP_CLASS);
+        this.container_updated.connect ( () => {
+                this.child_count = this.count_children ();
+            });
+        this.child_count = this.count_children ();
+    }
+
+    public virtual int count_children () {
+        try {
+            return this.media_db.get_child_count (this.id);
+        } catch (DatabaseError error) {
+            debug ("Could not get child count from database: %s",
+                   error.message);
+            return 0;
+        }
+    }
+
+    public override async MediaObjects? get_children (
+                                                     uint         offset,
+                                                     uint         max_count,
+                                                     string       sort_criteria,
+                                                     Cancellable? cancellable)
+                                                     throws GLib.Error {
+        return this.media_db.get_children (this,
+                                           sort_criteria,
+                                           offset,
+                                           max_count);
+    }
+
+    public virtual async MediaObjects? search (SearchExpression? expression,
+                                               uint              offset,
+                                               uint              max_count,
+                                               out uint          total_matches,
+                                               string            sort_criteria,
+                                               Cancellable?      cancellable)
+                                               throws GLib.Error {
+        MediaObjects children = null;
+
+        try {
+            children = this.media_db.get_objects_by_search_expression
+                                        (expression,
+                                         this.id,
+                                         sort_criteria,
+                                         offset,
+                                         max_count,
+                                         out total_matches);
+        } catch (MediaCacheError error) {
+            if (error is MediaCacheError.UNSUPPORTED_SEARCH) {
+                children = yield this.simple_search (expression,
+                                                     offset,
+                                                     max_count,
+                                                     out total_matches,
+                                                     sort_criteria,
+                                                     cancellable);
+            } else {
+                throw error;
+            }
+        }
+
+        return children;
+    }
+
+    public override async MediaObject? find_object (string       id,
+                                                    Cancellable? cancellable)
+                                                    throws Error {
+            return this.media_db.get_object (id);
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-dummy-container.vala b/src/plugins/odid/rygel-odid-dummy-container.vala
new file mode 100644
index 0000000..2048afd
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-dummy-container.vala
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2009,2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ * 
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+internal class Rygel.ODID.DummyContainer : TrackableDbContainer {
+    public File file;
+    public Gee.List<string> children;
+
+    public DummyContainer (File           file,
+                           MediaContainer parent) {
+        var cache = MediaCache.get_default ();
+
+        base (MediaCache.get_id (file.get_uri ()), file.get_basename ());
+
+        uint32 object_update_id, container_update_id, total_deleted_child_count;
+        this.media_db.get_track_properties (this.id,
+                                            out object_update_id,
+                                            out container_update_id,
+                                            out total_deleted_child_count);
+        this.object_update_id = object_update_id;
+        this.update_id = container_update_id;
+        this.total_deleted_child_count = total_deleted_child_count;
+
+        this.parent_ref = parent;
+        this.file = file;
+        this.uris.add (file.get_uri ());
+        try {
+            this.children = cache.get_child_ids (this.id);
+            this.child_count = this.children.size;
+        } catch (Error error) {
+            this.children = new ArrayList<string> ();
+            this.child_count = 0;
+        }
+    }
+
+    public void seen (File file) {
+        this.children.remove (MediaCache.get_id (file.get_uri ()));
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-harvester.vala b/src/plugins/odid/rygel-odid-harvester.vala
new file mode 100644
index 0000000..79a8a22
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-harvester.vala
@@ -0,0 +1,242 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ * 
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+/**
+ * This class takes care of the book-keeping of running and finished
+ * extraction tasks running within the odid plugin
+ */
+internal class Rygel.ODID.Harvester : GLib.Object {
+    private const uint FILE_CHANGE_DEFAULT_GRACE_PERIOD = 5;
+
+    private HashMap<File, HarvestingTask> tasks;
+    private HashMap<File, uint> extraction_grace_timers;
+    private RecursiveFileMonitor monitor;
+    private Cancellable cancellable;
+
+    // Properties
+    public ArrayList<File> locations { get; private set; }
+
+    public signal void done ();
+
+    /**
+     * Create a new instance of the meta-data extraction manager.
+     */
+    public Harvester (Cancellable     cancellable,
+                      ArrayList<File> locations) {
+        this.cancellable = cancellable;
+        this.locations = new ArrayList<File> ((EqualDataFunc<File>) File.equal);
+        foreach (var file in locations) {
+            if (file.query_exists ()) {
+                this.locations.add (file);
+            }
+        }
+
+        this.monitor = new RecursiveFileMonitor (cancellable);
+        this.monitor.changed.connect (this.on_file_changed);
+
+        this.tasks = new HashMap<File, HarvestingTask>
+                                        ((HashDataFunc<File>) File.hash,
+                                         (EqualDataFunc<File>) File.equal);
+        this.extraction_grace_timers = new HashMap<File, uint>
+                                        ((HashDataFunc<File>) File.hash,
+                                         (EqualDataFunc<File>) File.equal);
+    }
+
+    /**
+     * Check if a FileInfo is considered for extraction
+     *
+     * @param info a FileInfo
+     * @return true if file should be extracted, false otherwise
+     */
+    public static bool is_eligible (FileInfo info) {
+        return info.get_name ().has_suffix (".item");
+    }
+
+    /**
+     * Schedule rescan of all top-level locations known to the harvester.
+     *
+     * @param parent top-level container of the files
+     */
+    public void schedule_locations (MediaContainer parent) {
+        foreach (var file in this.locations) {
+            this.schedule (file, parent);
+        }
+    }
+
+    /**
+     * Put a file on queue for meta-data extraction
+     *
+     * @param file the file to investigate
+     * @param parent container of the filer to be harvested
+     */
+    public void schedule (File           file,
+                          MediaContainer parent) {
+        this.extraction_grace_timers.unset (file);
+
+        // Cancel a probably running harvester
+        this.cancel (file);
+
+        var task = new HarvestingTask (this.monitor,
+                                       file,
+                                       parent);
+        task.cancellable = this.cancellable;
+        task.completed.connect (this.on_file_harvested);
+        this.tasks[file] = task;
+        task.run.begin ();
+    }
+
+    /**
+     * Cancel a running meta-data extraction run
+     *
+     * @param file file cancel the current run for
+     */
+    public void cancel (File file) {
+        if (this.tasks.has_key (file)) {
+            var task = this.tasks[file];
+            task.completed.disconnect (this.on_file_harvested);
+            this.tasks.unset (file);
+            task.cancel ();
+        }
+    }
+
+    /**
+     * Callback for finished harvester.
+     *
+     * Updates book-keeping hash.
+     * @param state_machine HarvestingTask sending the event
+     */
+    private void on_file_harvested (StateMachine state_machine) {
+        var task = state_machine as HarvestingTask;
+        var file = task.origin;
+        message (_("'%s' harvested"), file.get_uri ());
+
+        this.tasks.unset (file);
+        if (this.tasks.is_empty) {
+            done ();
+        }
+    }
+
+    private void on_file_changed (File             file,
+                                  File?            other,
+                                  FileMonitorEvent event) {
+        try {
+            switch (event) {
+                case FileMonitorEvent.CREATED:
+                case FileMonitorEvent.CHANGES_DONE_HINT:
+                    this.on_changes_done (file);
+                    break;
+                case FileMonitorEvent.DELETED:
+                    this.on_file_removed (file);
+                    break;
+                default:
+                    break;
+            }
+        } catch (Error error) { }
+    }
+
+    private void on_file_added (File file) {
+        debug ("Filesystem events settled for %s, scheduling extraction…",
+               file.get_uri ());
+        try {
+            var cache = MediaCache.get_default ();
+            var info = file.query_info (FileAttribute.STANDARD_TYPE + "," +
+                                        FileAttribute.STANDARD_CONTENT_TYPE,
+                                        FileQueryInfoFlags.NONE,
+                                        this.cancellable);
+            if (info.get_file_type () == FileType.DIRECTORY ||
+                Harvester.is_eligible (info)) {
+                var id = MediaCache.get_id (file.get_parent ().get_uri ());
+                try {
+                    var parent_container = cache.get_object (id)
+                                        as MediaContainer;
+                    this.schedule (file, parent_container);
+                } catch (DatabaseError error) {
+                    warning (_("Error fetching object '%s' from database: %s"),
+                            id,
+                            error.message);
+                }
+            } else {
+                debug ("%s is not eligible for extraction", file.get_uri ());
+            }
+        } catch (Error error) {
+            warning (_("Failed to query info of a file %s: %s"),
+                     file.get_uri (),
+                     error.message);
+        }
+    }
+
+    private void on_file_removed (File file) {
+        var cache = MediaCache.get_default ();
+        if (this.extraction_grace_timers.has_key (file)) {
+            Source.remove (this.extraction_grace_timers[file]);
+            this.extraction_grace_timers.unset (file);
+        }
+
+        this.cancel (file);
+        try {
+            // the full object is fetched instead of simply calling
+            // exists because we need the parent to signal the
+            // change
+            var id = MediaCache.get_id (file.get_uri ());
+            var object = cache.get_object (id);
+            var parent = null as MediaContainer;
+
+            if (object != null) {
+                parent = object.parent;
+                if (parent is TrackableContainer) {
+                    var container = parent as TrackableContainer;
+                    container.remove_child_tracked.begin (object);
+                }
+            }
+        } catch (Error error) {
+            warning (_("Error removing object from database: %s"),
+                     error.message);
+        }
+    }
+
+    private void on_changes_done (File file) throws Error {
+        if (file.get_basename ().has_prefix (".")) {
+            return;
+        }
+
+        if (this.extraction_grace_timers.has_key (file)) {
+            Source.remove (this.extraction_grace_timers[file]);
+        } else {
+            debug ("Starting grace timer for harvesting %s…",
+                    file.get_uri ());
+        }
+
+        SourceFunc callback = () => {
+            this.on_file_added (file);
+
+            return false;
+        };
+
+        var timeout = Timeout.add_seconds (FILE_CHANGE_DEFAULT_GRACE_PERIOD,
+                                           (owned) callback);
+        this.extraction_grace_timers[file] = timeout;
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-harvesting-task.vala b/src/plugins/odid/rygel-odid-harvesting-task.vala
new file mode 100755
index 0000000..ba9e791
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-harvesting-task.vala
@@ -0,0 +1,369 @@
+/*
+ * Copyright (C) 2009 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ * 
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using GLib;
+using Gee;
+
+internal class FileQueueEntry {
+    public File file;
+    public bool known;
+    public string content_type;
+
+    public FileQueueEntry (File file, bool known, string content_type) {
+        this.file = file;
+        this.known = known;
+        this.content_type = content_type;
+    }
+}
+
+public class Rygel.ODID.HarvestingTask : Rygel.StateMachine,
+                                                GLib.Object {
+    public File origin;
+    private MediaCache cache;
+    private GLib.Queue<MediaContainer> containers;
+    private Gee.Queue<FileQueueEntry> files;
+    private RecursiveFileMonitor monitor;
+    private MediaContainer parent;
+    private const int BATCH_SIZE = 256;
+
+    public Cancellable cancellable { get; set; }
+
+    private const string HARVESTER_ATTRIBUTES =
+                                        FileAttribute.STANDARD_NAME + "," +
+                                        FileAttribute.STANDARD_TYPE + "," +
+                                        FileAttribute.TIME_MODIFIED + "," +
+                                        FileAttribute.STANDARD_CONTENT_TYPE + "," +
+                                        FileAttribute.STANDARD_SIZE + "," +
+                                        FileAttribute.STANDARD_IS_HIDDEN;
+
+    public HarvestingTask (RecursiveFileMonitor monitor,
+                           File                 file,
+                           MediaContainer       parent) {
+        this.origin = file;
+        this.parent = parent;
+        this.cache = MediaCache.get_default ();
+
+        this.files = new LinkedList<FileQueueEntry> ();
+        this.containers = new GLib.Queue<MediaContainer> ();
+        this.monitor = monitor;
+    }
+
+    public void cancel () {
+        // detach from common cancellable; otherwise everything would be
+        // cancelled like file monitoring, other harvesters etc.
+        this.cancellable = new Cancellable ();
+        this.cancellable.cancel ();
+    }
+
+    /**
+     * Extract all metainformation from a given file.
+     *
+     * What action will be taken depends on the arguments
+     * * file is a simple file. Then only information of this
+     *   file will be extracted
+     * * file is a directory and recursive is false. The children
+     *   of the directory (if not directories themselves) will be
+     *   enqueued for extraction
+     * * file is a directory and recursive is true. ++ All ++ children
+     *   of the directory will be enqueued for extraction, even directories
+     *
+     * No matter how many children are contained within file's hierarchy,
+     * only one event is sent when all the children are done.
+     */
+    public async void run () {
+        try {
+            var info = yield this.origin.query_info_async
+                                        (HARVESTER_ATTRIBUTES,
+                                         FileQueryInfoFlags.NONE,
+                                         Priority.DEFAULT,
+                                         this.cancellable);
+
+            if (this.process_file (this.origin, info, this.parent)) {
+                if (info.get_file_type () != FileType.DIRECTORY) {
+                    this.containers.push_tail (this.parent);
+                }
+                this.on_idle ();
+            } else {
+                this.completed ();
+            }
+        } catch (Error error) {
+            if (!(error is IOError.CANCELLED)) {
+                warning (_("Failed to harvest file %s: %s"),
+                         this.origin.get_uri (),
+                         error.message);
+            } else {
+                debug ("Harvesting of uri %s was cancelled",
+                       this.origin.get_uri ());
+            }
+            this.completed ();
+        }
+    }
+
+    /**
+     * Add a file to the meta-data extraction queue.
+     *
+     * The file will only be added to the queue if one of the following
+     * conditions is met:
+     *   - The file is not in the cache
+     *   - The current mtime of the file is larger than the cached
+     *   - The size has changed
+     * @param file to check
+     * @param info FileInfo of the file to check
+     * @return true, if the file has been queued, false otherwise.
+     */
+    private bool push_if_changed_or_unknown (File       file,
+                                             FileInfo   info) {
+        int64 timestamp;
+        int64 size;
+        try {
+            if (this.cache.exists (file, out timestamp, out size)) {
+                int64 mtime = (int64) info.get_attribute_uint64
+                                        (FileAttribute.TIME_MODIFIED);
+
+                if (mtime > timestamp ||
+                    info.get_size () != size) {
+                    var entry = new FileQueueEntry (file,
+                                                    true,
+                                                    info.get_content_type ());
+                    this.files.offer (entry);
+
+                    return true;
+                }
+            } else {
+                var entry = new FileQueueEntry (file,
+                                                false,
+                                                info.get_content_type ());
+                this.files.offer (entry);
+
+                return true;
+            }
+        } catch (Error error) {
+            warning (_("Failed to query database: %s"), error.message);
+        }
+
+        return false;
+    }
+
+    private bool process_file (File           file,
+                               FileInfo       info,
+                               MediaContainer parent) {
+        if (info.get_is_hidden ()) {
+            return false;
+        }
+
+        if (info.get_file_type () == FileType.DIRECTORY) {
+            // queue directory for processing later
+            this.monitor.add.begin (file);
+
+            var container = new DummyContainer (file, parent);
+            this.containers.push_tail (container);
+
+            // Only add new containers. There's not much about a container so
+            // we skip the updated signal
+            var dummy_parent = parent as DummyContainer;
+            if (dummy_parent == null ||
+                !dummy_parent.children.contains (MediaCache.get_id (file.get_uri ()))) {
+                (parent as TrackableContainer).add_child_tracked.begin (container);
+            }
+
+            return true;
+        } else {
+            // Check if the file needs to be harvested at all either because
+            // it is denied by filter or it hasn't updated
+            if (Harvester.is_eligible (info)) {
+                return this.push_if_changed_or_unknown (file, info);
+            }
+
+            return false;
+        }
+    }
+
+    private bool process_children (GLib.List<FileInfo>? list) {
+        if (list == null || this.cancellable.is_cancelled ()) {
+            return false;
+        }
+
+        int count = 0;
+        // Discover if this is an ODID directory, single .item file.
+        foreach (var info in list) {
+            if (info.get_name ().has_suffix (".item")) {
+                count++;
+            }
+        }
+
+        bool isOdidDirectory = count == 1 ? true : false;
+
+        var container = this.containers.peek_head () as DummyContainer;
+
+        foreach (var info in list) {
+            var file = container.file.get_child (info.get_name ());
+            // Skip processing ODID resource directories.
+            if (!(isOdidDirectory && info.get_file_type () == FileType.DIRECTORY)   ) {
+                this.process_file (file, info, container);
+                container.seen (file);
+            }
+        }
+
+        return true;
+    }
+
+    private async void enumerate_directory () {
+        var directory = (this.containers.peek_head () as DummyContainer).file;
+        try {
+            var enumerator = yield directory.enumerate_children_async
+                                        (HARVESTER_ATTRIBUTES,
+                                         FileQueryInfoFlags.NONE,
+                                         Priority.DEFAULT,
+                                         this.cancellable);
+
+            GLib.List<FileInfo> list = null;
+            do {
+                list = yield enumerator.next_files_async (BATCH_SIZE,
+                                                          Priority.DEFAULT,
+                                                          this.cancellable);
+            } while (this.process_children (list));
+
+            yield enumerator.close_async (Priority.DEFAULT, this.cancellable);
+        } catch (Error err) {
+            warning (_("failed to enumerate folder: %s"), err.message);
+        }
+
+        this.cleanup_database ();
+        this.do_update ();
+    }
+
+    private void cleanup_database () {
+        var container = this.containers.peek_head () as DummyContainer;
+
+        // delete all children which are not in filesystem anymore
+        try {
+            foreach (var child in container.children) {
+                this.cache.remove_by_id (child);
+            }
+        } catch (DatabaseError error) {
+            warning (_("Failed to get children of container %s: %s"),
+                     container.id,
+                     error.message);
+        }
+    }
+
+    private bool on_idle () {
+        if (this.cancellable.is_cancelled ()) {
+            this.completed ();
+
+            return false;
+        }
+
+        if (!this.files.is_empty) {
+            debug ("Scheduling file %s for meta-data extraction…",
+                   this.files.peek ().file.get_uri ());
+
+			if (this.files.peek ().file.get_basename ().has_suffix (".item")) {
+				this.read_odid (this.files.peek ().file);
+			}
+			
+        } else if (!this.containers.is_empty ()) {
+            this.enumerate_directory.begin ();
+        } else {
+            // nothing to do
+            this.completed ();
+        }
+
+        return false;
+    }
+
+    private void read_odid (File file) {
+        if (this.cancellable.is_cancelled ()) {
+            this.completed ();
+        }
+
+        KeyFile keyFile = new KeyFile ();
+        try {
+            keyFile.load_from_file (file.get_path (),
+                                   KeyFileFlags.KEEP_COMMENTS |
+                                   KeyFileFlags.KEEP_TRANSLATIONS);
+
+            string id = MediaCache.get_id (file.get_uri ());
+
+            MediaItem item = new Rygel.ODID.MediaItem
+                                     (id, this.containers.peek_head (),
+                                      keyFile.get_string ("item", "title"));
+
+            if (keyFile.has_key ("item", "date"))    {
+                item.date = keyFile.get_string ("item", "date");
+            }
+
+            if (keyFile.has_key ("item", "creator"))    {
+                item.creator = keyFile.get_string ("item", "creator");
+            }
+
+            var media_engine = MediaEngine.get_default ( );
+
+            // Note: Call async, since it can block for some time
+
+            item.add_uri (file.get_uri ());
+
+            media_engine.get_resources_for_item.begin ( item,
+                                                        (obj, res) => {
+                var engine_resources = media_engine.get_resources_for_item.end (res);
+                debug ( "Received %s resources for %s",
+                        ((engine_resources == null)
+                         ? "NO" : engine_resources.size.to_string () ),
+                        file.get_uri ().to_string () );
+                item.get_resource_list ().add_all (engine_resources);
+
+                if (item != null) {
+                    item.parent_ref = this.containers.peek_head ();
+                    // This is only necessary to generate the proper <objAdd LastChange entry
+                    if (this.files.peek ().known) {
+                        (item as UpdatableObject).non_overriding_commit.begin ();
+                    } else {
+                        var container = item.parent as TrackableContainer;
+                        container.add_child_tracked.begin (item) ;
+                    }
+                }
+
+                this.files.poll ();
+                this.do_update ();
+            }); 
+        } catch (Error error) {
+            warning ("Unable to read item file %s, Message: %s",
+                     file.get_path (), error.message);
+        }
+    }
+
+    /**
+     * If all files of a container were processed, notify the container
+     * about this and set the updating signal.
+     * Reschedule the iteration and extraction
+     */
+    private void do_update () {
+        if (this.files.is_empty &&
+            !this.containers.is_empty ()) {
+            this.containers.pop_head ();
+        }
+
+        this.on_idle ();
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-jpeg-writer.vala b/src/plugins/odid/rygel-odid-jpeg-writer.vala
new file mode 100644
index 0000000..bea7869
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-jpeg-writer.vala
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gst;
+
+/**
+ * Utility class to write media-art content to JPEG files
+ *
+ * This uses a gstreamer pipeline to transcode the image tag as contained in
+ * MP3 files. This class is single-shot, use and then throw away.
+ */
+internal class Rygel.ODID.JPEGWriter : GLib.Object {
+    private Bin bin;
+    private App.Src appsrc;
+    private MainLoop loop;
+    private dynamic Element sink;
+
+    public JPEGWriter () throws Error {
+        this.bin = Gst.parse_launch ("appsrc name=src ! decodebin ! " +
+                                     "autovideoconvert ! " +
+                                     "jpegenc ! giosink name=sink") as Bin;
+        this.appsrc = bin.get_by_name ("src") as App.Src;
+        this.sink = bin.get_by_name ("sink");
+        var bus = bin.get_bus ();
+        bus.add_signal_watch ();
+        bus.message["eos"].connect(() => { this.loop.quit (); });
+        bus.message["error"].connect(() => { this.loop.quit (); });
+        this.loop = new MainLoop (null, false);
+    }
+
+    /**
+     * Write a Gst.Buffer as retrieved from the Gst.TagList to disk.
+     *
+     * @param buffer The Gst.Buffer as obtained from tag list
+     * @param file   A GLib.File pointing to the target location
+     *
+     * FIXME This uses a nested main-loop to block which is ugly.
+     */
+    public void write (Gst.Buffer buffer, File file) {
+        Gst.FlowReturn flow;
+        this.sink.file = file;
+        Signal.emit_by_name (appsrc, "push-buffer", buffer, out flow);
+        this.appsrc.end_of_stream ();
+        this.bin.set_state (State.PLAYING);
+        this.loop.run ();
+        this.bin.set_state (State.NULL);
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-leaf-query-container.vala b/src/plugins/odid/rygel-odid-leaf-query-container.vala
new file mode 100644
index 0000000..732a5c3
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-leaf-query-container.vala
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ * 
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+internal class Rygel.ODID.LeafQueryContainer : QueryContainer {
+    public LeafQueryContainer (SearchExpression expression,
+                               string           id,
+                               string           name) {
+        Object (id : id,
+                title : name,
+                parent : null,
+                child_count : 0,
+                expression : expression);
+    }
+
+    public override async MediaObjects? get_children
+                                        (uint         offset,
+                                         uint         max_count,
+                                         string       sort_criteria,
+                                         Cancellable? cancellable)
+                                         throws GLib.Error {
+        uint total_matches;
+        var children = yield this.search (null,
+                                          offset,
+                                          max_count,
+                                          out total_matches,
+                                          sort_criteria,
+                                          cancellable);
+        foreach (var child in children) {
+            var container_id = QueryContainer.ITEM_PREFIX +
+                               this.id.replace (QueryContainer.PREFIX, "");
+            child.ref_id = child.id;
+            child.id = container_id + ":" + child.ref_id;
+            child.parent = this;
+        }
+
+        return children;
+    }
+
+    public override int count_children () {
+        try {
+            return (int) this.media_db.get_object_count_by_search_expression
+                                        (this.expression, null);
+        } catch (Error error) {
+            return 0;
+        }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-media-cache-upgrader.vala b/src/plugins/odid/rygel-odid-media-cache-upgrader.vala
new file mode 100644
index 0000000..854a50c
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-media-cache-upgrader.vala
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+internal class Rygel.ODID.MediaCacheUpgrader {
+    private unowned Database database;
+    private unowned SQLFactory sql;
+
+    public MediaCacheUpgrader (Database database, SQLFactory sql) {
+        this.database = database;
+        this.sql = sql;
+    }
+
+    public bool needs_upgrade (out int current_version) throws Error {
+        current_version = this.database.query_value (
+                                        "SELECT version FROM schema_info");
+
+        return current_version < int.parse (SQLFactory.SCHEMA_VERSION);
+    }
+
+    public void fix_schema () throws Error {
+    }
+
+    public void ensure_indices () {
+        try {
+            this.database.exec (this.sql.make (SQLString.INDEX_COMMON));
+            this.database.analyze ();
+        } catch (Error error) {
+            warning ("Failed to create indices: " +
+                     error.message);
+        }
+    }
+
+    public void upgrade (int old_version) {
+        debug ("Older schema detected. Upgrading...");
+        int current_version = int.parse (SQLFactory.SCHEMA_VERSION);
+        while (old_version < current_version) {
+            if (this.database == null) {
+                break;
+            }
+
+            switch (old_version) {
+                case 1:
+                    update_v1_v2 ();
+                    break;
+                case 2:
+                    update_v2_v3 ();
+                    break;
+                default:
+                    warning ("Cannot upgrade");
+                    database = null;
+                    break;
+            }
+            old_version++;
+        }
+    }
+
+    private void update_v1_v2 () {
+        try {
+            database.begin ();
+            database.exec ("ALTER TABLE resource ADD COLUMN external_uri TEXT");
+            database.exec ("UPDATE schema_info SET version = '2'");
+            database.commit ();
+        } catch (DatabaseError error) {
+            database.rollback ();
+            warning ("Database upgrade failed: %s", error.message);
+            database = null;
+        }
+    }
+
+    private void update_v2_v3 () {
+        try {
+            database.begin ();
+            database.exec ("ALTER TABLE resource ADD COLUMN residx INTEGER DEFAULT 1");
+            database.exec ("UPDATE schema_info SET version = '3'");
+            database.commit ();
+        } catch (DatabaseError error) {
+            database.rollback ();
+            warning ("Database upgrade failed: %s", error.message);
+            database = null;
+        }
+    }
+
+    public void force_reindex () throws DatabaseError {
+        database.exec ("UPDATE Object SET timestamp = 0");
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-media-cache.vala b/src/plugins/odid/rygel-odid-media-cache.vala
new file mode 100644
index 0000000..7acc381
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-media-cache.vala
@@ -0,0 +1,1133 @@
+/*
+ * Copyright (C) 2009,2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Intel Corporation.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */    
+
+using Gee;
+using GUPnP;
+using Sqlite;
+
+public errordomain Rygel.ODID.MediaCacheError {
+    SQLITE_ERROR,
+    GENERAL_ERROR,
+    INVALID_TYPE,
+    UNSUPPORTED_SEARCH
+}
+
+internal enum Rygel.ODID.ObjectType {
+    CONTAINER,
+    ITEM
+}
+
+internal struct Rygel.ODID.ExistsCacheEntry {
+    int64 mtime;
+    int64 size;
+}
+
+/**
+ * Persistent storage of media objects.
+ *
+ * ODIDDB is a sqlite3 backed persistent storage of media objects.
+ */
+public class Rygel.ODID.MediaCache : Object {
+    // Private members
+    private Database                           db;
+    private ObjectFactory                      factory;
+    private SQLFactory                         sql;
+    private HashMap<string, ExistsCacheEntry?> exists_cache;
+
+    // Private static members
+    private static MediaCache instance;
+
+    // Constructors
+    private MediaCache () throws Error {
+        var db_name = "odid";
+        try {
+            var config = MetaConfig.get_default ();
+            if (config.get_bool (ODID.Plugin.NAME, "use-temp-db")) {
+                db_name = ":memory:";
+            }
+        } catch (Error error) { }
+        this.sql = new SQLFactory ();
+        debug ("Opening database %s", db_name);
+        this.open_db (db_name);
+        this.factory = new ObjectFactory ();
+    }
+
+    // Public static functions
+    public static string get_id (string uri) {
+        return Checksum.compute_for_string (ChecksumType.MD5, uri);
+    }
+
+    public static void ensure_exists () throws Error {
+        if (MediaCache.instance == null) {
+            MediaCache.instance = new MediaCache ();
+        }
+    }
+
+    public static MediaCache get_default () {
+        return MediaCache.instance;
+    }
+
+    // Public functions
+    public void remove_by_id (string id) throws DatabaseError {
+        GLib.Value[] values = { id };
+        this.db.exec (this.sql.make (SQLString.DELETE), values);
+    }
+
+    public void remove_object (MediaObject object) throws DatabaseError,
+                                                          MediaCacheError {
+        this.remove_by_id (object.id);
+    }
+
+    /**
+     * Add the container to the cache, in a database transcation,
+     * rolling back the transaction if necessary.
+     */
+    public void save_container (MediaContainer container) throws Error {
+        try {
+            db.begin ();
+            create_object (container);
+            db.commit ();
+        } catch (DatabaseError error) {
+            db.rollback ();
+
+            throw error;
+        }
+    }
+
+    /**
+     * Add the item to the cache.
+     */
+    public void save_item (Rygel.ODID.MediaItem item,
+                           bool override_guarded = false) throws Error {
+        try {
+            db.begin ();
+            save_resources (item);
+            create_object (item, override_guarded);
+            db.commit ();
+        } catch (DatabaseError error) {
+            warning (_("Failed to add item with ID %s: %s"),
+                     item.id,
+                     error.message);
+            db.rollback ();
+
+            throw error;
+        }
+    }
+
+    /**
+     * Create a new container or item instance based on the ID.
+     *
+     * The Rygel server discards the object when the browse request is finished,
+     * after serializing the result.
+     */
+    public MediaObject? get_object (string object_id) throws DatabaseError {
+        GLib.Value[] values = { object_id };
+        MediaObject parent = null;
+
+        var cursor = this.exec_cursor (SQLString.GET_OBJECT, values);
+
+        foreach (var statement in cursor) {
+            var parent_container = parent as MediaContainer;
+            var object = this.get_object_from_statement
+                                        (parent_container,
+                                         statement);
+            object.parent_ref = parent_container;
+            parent = object;
+        }
+
+        return parent;
+    }
+
+    public MediaContainer? get_container (string container_id)
+                                          throws DatabaseError,
+                                                 MediaCacheError {
+        var object = get_object (container_id);
+        if (object != null && !(object is MediaContainer)) {
+            throw new MediaCacheError.INVALID_TYPE ("Object with id %s is " +
+                                                    "not a MediaContainer",
+                                                    container_id);
+        }
+
+        return object as MediaContainer;
+    }
+
+    public int get_child_count (string container_id) throws DatabaseError {
+        GLib.Value[] values = { container_id };
+
+        return this.query_value (SQLString.CHILD_COUNT, values);
+    }
+
+    public uint32 get_update_id () {
+        // Return the highest object ID in the database so far.
+        try {
+            return this.query_value (SQLString.MAX_UPDATE_ID);
+        } catch (Error error) { }
+
+        return 0;
+    }
+
+    public void get_track_properties (string id,
+                                      out uint32 object_update_id,
+                                      out uint32 container_update_id,
+                                      out uint32 total_deleted_child_count) {
+        GLib.Value[] values = { id };
+
+        object_update_id = 0;
+        container_update_id = 0;
+        total_deleted_child_count = 0;
+
+        try {
+            var cursor = this.db.exec_cursor ("SELECT object_update_id, " +
+                                              "container_update_id, " +
+                                              "deleted_child_count " +
+                                              "FROM Object WHERE upnp_id = ?",
+                                              values);
+            var statement = cursor.next ();
+            object_update_id = (uint32) statement->column_int64 (0);
+            container_update_id = (uint32) statement->column_int64 (1);
+            total_deleted_child_count = (uint32) statement->column_int64 (2);
+
+            return;
+        } catch (Error error) {
+            warning ("Failed to get update ids: %s", error.message);
+        }
+
+    }
+
+    public bool exists (File      file,
+                        out int64 timestamp,
+                        out int64 size) throws DatabaseError {
+        var uri = file.get_uri ();
+        GLib.Value[] values = { uri };
+
+        if (this.exists_cache.has_key (uri)) {
+            var entry = this.exists_cache.get (uri);
+            this.exists_cache.unset (uri);
+            timestamp = entry.mtime;
+            size = entry.size;
+
+            return true;
+        }
+
+        var cursor = this.exec_cursor (SQLString.EXISTS, values);
+        var statement = cursor.next ();
+        timestamp = statement->column_int64 (1);
+
+        // Placeholder item
+        if (timestamp == int64.MAX) {
+            timestamp = 0;
+        }
+        size = statement->column_int64 (2);
+
+        return statement->column_int (0) == 1;
+    }
+
+    public MediaObjects get_children (MediaContainer container,
+                                      string         sort_criteria,
+                                      long           offset,
+                                      long           max_count)
+                                      throws Error {
+        MediaObjects children = new MediaObjects ();
+
+        GLib.Value[] values = { container.id,
+                                offset,
+                                max_count };
+
+        var sql = this.sql.make (SQLString.GET_CHILDREN);
+
+        var sort_order = MediaCache.translate_sort_criteria (sort_criteria);
+        var cursor = this.db.exec_cursor (sql.printf (sort_order), values);
+
+        foreach (var statement in cursor) {
+            children.add (this.get_object_from_statement (container,
+                                                          statement));
+            children.last ().parent_ref = container;
+        }
+
+        return children;
+    }
+
+    public MediaObjects get_objects_by_search_expression
+                                        (SearchExpression? expression,
+                                         string?           container_id,
+                                         string            sort_criteria,
+                                         uint              offset,
+                                         uint              max_count,
+                                         out uint          total_matches)
+                                         throws Error {
+        var args = new GLib.ValueArray (0);
+        var filter = MediaCache.translate_search_expression (expression, args);
+
+        if (expression != null) {
+            debug ("Original search: %s", expression.to_string ());
+            debug ("Parsed search expression: %s", filter);
+        }
+
+        var max_objects = modify_limit (max_count);
+        total_matches = (uint) get_object_count_by_filter (filter,
+                                                           args,
+                                                           container_id);
+
+        return this.get_objects_by_filter (filter,
+                                           args,
+                                           container_id,
+                                           sort_criteria,
+                                           offset,
+                                           max_objects);
+    }
+
+    public long get_object_count_by_search_expression
+                                        (SearchExpression? expression,
+                                         string?           container_id)
+                                         throws Error {
+        var args = new GLib.ValueArray (0);
+        var filter = MediaCache.translate_search_expression (expression, args);
+
+        if (expression != null) {
+            debug ("Original search: %s", expression.to_string ());
+            debug ("Parsed search expression: %s", filter);
+        }
+
+        for (int i = 0; i < args.n_values; i++) {
+            var arg = args.get_nth (i);
+            debug ("Arg %d: %s", i, arg.holds (typeof (string)) ?
+                                        arg.get_string () :
+                                        arg.strdup_contents ());
+        }
+
+        return this.get_object_count_by_filter (filter,
+                                                args,
+                                                container_id);
+    }
+
+    public long get_object_count_by_filter
+                                        (string          filter,
+                                         GLib.ValueArray args,
+                                         string?         container_id)
+                                         throws Error {
+        if (container_id != null) {
+            GLib.Value v = container_id;
+            args.prepend (v);
+        }
+
+        debug ("Parameters to bind: %u", args.n_values);
+        unowned string pattern;
+        SQLString string_id;
+        if (container_id != null) {
+            string_id = SQLString.GET_OBJECT_COUNT_BY_FILTER_WITH_ANCESTOR;
+        } else {
+            string_id = SQLString.GET_OBJECT_COUNT_BY_FILTER;
+        }
+        pattern = this.sql.make (string_id);
+
+        return this.db.query_value (pattern.printf (filter), args.values);
+    }
+
+    public MediaObjects get_objects_by_filter (string          filter,
+                                               GLib.ValueArray args,
+                                               string?         container_id,
+                                               string          sort_criteria,
+                                               long            offset,
+                                               long            max_count)
+                                               throws Error {
+        var children = new MediaObjects ();
+        GLib.Value v = offset;
+        args.append (v);
+        v = max_count;
+        args.append (v);
+        MediaContainer parent = null;
+
+        debug ("Parameters to bind: %u", args.n_values);
+        for (int i = 0; i < args.n_values; i++) {
+            var arg = args.get_nth (i);
+            debug ("Arg %d: %s", i, arg.holds (typeof (string)) ?
+                                        arg.get_string () :
+                                        arg.strdup_contents ());
+        }
+
+        unowned string sql;
+        if (container_id != null) {
+            sql = this.sql.make (SQLString.GET_OBJECTS_BY_FILTER_WITH_ANCESTOR);
+        } else {
+            sql = this.sql.make (SQLString.GET_OBJECTS_BY_FILTER);
+        }
+
+        var sort_order = MediaCache.translate_sort_criteria (sort_criteria);
+        var cursor = this.db.exec_cursor (sql.printf (filter, sort_order),
+                                          args.values);
+        foreach (var statement in cursor) {
+            unowned string parent_id = statement.column_text (ObjectColumn.PARENT);
+
+            if (parent == null || parent_id != parent.id) {
+                if (parent_id == null) {
+                    parent = new NullContainer.root ();
+                } else {
+                    parent = new NullContainer (parent_id, null, "ODID");
+                }
+            }
+
+            if (parent != null) {
+                children.add (this.get_object_from_statement (parent,
+                                                              statement));
+                children.last ().parent_ref = parent;
+            } else {
+                warning ("Inconsistent database: item %s " +
+                         "has no parent %s",
+                         statement.column_text (ObjectColumn.ID),
+                         parent_id);
+            }
+        }
+
+        return children;
+    }
+
+    public void debug_statistics () {
+        try {
+            debug ("Database statistics:");
+            var cursor = this.exec_cursor (SQLString.STATISTICS);
+            foreach (var statement in cursor) {
+                debug ("%s: %d",
+                       statement.column_text (0),
+                       statement.column_int (1));
+            }
+        } catch (Error error) { }
+    }
+
+    public ArrayList<string> get_child_ids (string container_id)
+                                            throws DatabaseError {
+        ArrayList<string> children = new ArrayList<string> ();
+        GLib.Value[] values = { container_id  };
+
+        var cursor = this.exec_cursor (SQLString.CHILD_IDS, values);
+        foreach (var statement in cursor) {
+            children.add (statement.column_text (0));
+        }
+
+        return children;
+    }
+
+    public Gee.List<string> get_meta_data_column_by_filter
+                                        (string          column,
+                                         string          filter,
+                                         GLib.ValueArray args,
+                                         long            offset,
+                                         long            max_count)
+                                         throws Error {
+        GLib.Value v = offset;
+        args.append (v);
+        v = max_count;
+        args.append (v);
+
+        var data = new ArrayList<string> ();
+
+        unowned string sql = this.sql.make (SQLString.GET_RESOURCE_COLUMN);
+        var cursor = this.db.exec_cursor (sql.printf (column, filter),
+                                          args.values);
+        foreach (var statement in cursor) {
+            data.add (statement.column_text (0));
+        }
+
+        return data;
+    }
+
+    /**
+     * TODO
+     */
+    public Gee.List<string> get_object_attribute_by_search_expression
+                                        (string            attribute,
+                                         SearchExpression? expression,
+                                         long              offset,
+                                         uint              max_count)
+                                         throws Error {
+        var args = new ValueArray (0);
+        var filter = MediaCache.translate_search_expression (expression,
+                                                             args,
+                                                             "AND");
+
+        debug ("Parsed filter: %s", filter);
+
+        var column = MediaCache.map_operand_to_column (attribute);
+        var max_objects = modify_limit (max_count);
+
+        return this.get_meta_data_column_by_filter (column,
+                                                    filter,
+                                                    args,
+                                                    offset,
+                                                    max_objects);
+    }
+
+    public string get_reset_token () {
+        try {
+            var cursor = this.exec_cursor (SQLString.RESET_TOKEN);
+            var statement = cursor.next ();
+
+            return statement->column_text (0);
+        } catch (DatabaseError error) {
+            warning ("Failed to get reset token");
+
+            return UUID.get ();
+        }
+    }
+
+    public void save_reset_token (string token) {
+        try {
+            GLib.Value[] args = { token };
+
+            this.db.exec ("UPDATE schema_info SET reset_token = ?", args);
+        } catch (DatabaseError error) {
+            warning ("Failed to persist ServiceResetToken: %s", error.message);
+        }
+    }
+
+    public void drop_virtual_folders () {
+        try {
+            this.db.exec ("DELETE FROM object WHERE " +
+                          "upnp_id LIKE 'virtual-parent:%'");
+        } catch (DatabaseError error) {
+            warning ("Failed to drop virtual folders: %s", error.message);
+        }
+    }
+
+    public void make_object_guarded (MediaObject object,
+                                     bool guarded = true) {
+        var guarded_val = guarded ? 1 : 0;
+
+        try {
+            GLib.Value[] values = { guarded_val,
+                                    object.id };
+
+            this.db.exec (this.sql.make (SQLString.MAKE_GUARDED), values);
+        } catch (DatabaseError error) {
+            warning ("Failed to mark item %s as guarded (%d): %s",
+                     object.id,
+                     guarded_val,
+                     error.message);
+        }
+    }
+
+    public string create_reference (MediaObject object, MediaContainer parent)
+                                    throws Error {
+        if (object is MediaContainer) {
+            var msg = _("Cannot create references to containers");
+
+            throw new MediaCacheError.GENERAL_ERROR (msg);
+        }
+
+        object.parent = parent;
+
+        // If the original is already a ref_id, point to the original item as
+        // we should not daisy-chain reference items.
+        if (object.ref_id == null) {
+            object.ref_id = object.id;
+        }
+        object.id = UUID.get ();
+
+        this.save_item (object as Rygel.ODID.MediaItem);
+
+        return object.id;
+    }
+
+    // Private functions
+    private bool is_object_guarded (string id) {
+        try {
+            GLib.Value[] id_value = { id };
+
+            return this.query_value (SQLString.IS_GUARDED,
+                                     id_value) == 1;
+        } catch (DatabaseError error) {
+            warning ("Failed to get whether item %s is guarded: %s",
+                     id,
+                     error.message);
+
+            return false;
+        }
+    }
+
+    public void rebuild_exists_cache () throws DatabaseError {
+        this.exists_cache = new HashMap<string, ExistsCacheEntry?> ();
+        var cursor = this.exec_cursor (SQLString.EXISTS_CACHE);
+        foreach (var statement in cursor) {
+            var entry = ExistsCacheEntry ();
+            entry.mtime = statement.column_int64 (1);
+            entry.size = statement.column_int64 (0);
+            this.exists_cache.set (statement.column_text (2), entry);
+        }
+    }
+
+    private uint modify_limit (uint max_count) {
+        if (max_count == 0) {
+            return -1;
+        } else {
+            return max_count;
+        }
+    }
+
+    private void open_db (string name) throws Error {
+        this.db = new Database (name);
+        int old_version = -1;
+        int current_version = int.parse (SQLFactory.SCHEMA_VERSION);
+
+        try {
+            var upgrader = new MediaCacheUpgrader (this.db, this.sql);
+            if (upgrader.needs_upgrade (out old_version)) {
+                upgrader.upgrade (old_version);
+            } else if (old_version == current_version) {
+                upgrader.fix_schema ();
+            } else {
+                warning ("The version \"%d\" of the detected database" +
+                         " is newer than our supported version \"%d\"",
+                         old_version,
+                         current_version);
+                this.db = null;
+
+                throw new MediaCacheError.GENERAL_ERROR ("Database format" +
+                                                         " not supported");
+            }
+            upgrader.ensure_indices ();
+        } catch (DatabaseError error) {
+            debug ("Could not find schema version;" +
+                   " checking for empty database...");
+            try {
+                var rows = this.db.query_value ("SELECT count(type) FROM " +
+                                                "sqlite_master WHERE rowid=1");
+                if (rows == 0) {
+                    debug ("Empty database, creating new schema version %s",
+                            SQLFactory.SCHEMA_VERSION);
+                    if (!create_schema ()) {
+                        this.db = null;
+
+                        return;
+                    }
+                } else {
+                    warning ("Incompatible schema... cannot proceed");
+                    this.db = null;
+
+                    return;
+                }
+            } catch (DatabaseError error) {
+                warning ("Something weird going on: %s", error.message);
+                this.db = null;
+
+                throw new MediaCacheError.GENERAL_ERROR ("Invalid database");
+            }
+        }
+    }
+
+    private void save_resources (Rygel.ODID.MediaItem item) throws Error {
+        // Remove and pre-existing resources
+        this.db.exec (this.sql.make (SQLString.DELETE_RESOURCES), { item.id });
+
+        int index = 0;
+        foreach (MediaResource resource in item.get_resource_list ())
+        {
+            // TODO: Remove this if/when ProtocolInfo.to_string is no longer used
+            resource.protocol = "http-get"; // Prevents ProtocolInfo assertion error
+            // Fill common properties
+            GLib.Value[] values = { resource.size,
+                                    resource.width,
+                                    resource.height,
+                                    Database.null (),
+                                    Database.null (),
+                                    resource.bitrate,
+                                    resource.sample_freq,
+                                    resource.bits_per_sample,
+                                    resource.audio_channels,
+                                    -1,
+                                    resource.color_depth,
+                                    resource.duration,
+                                    item.id,
+                                    resource.get_protocol_info ().to_string (),
+                                    resource.cleartext_size,
+                                    Database.null (),
+                                    -1,
+                                    resource.uri,
+                                    resource.get_name (),
+                                    resource.extension,
+                                    index++ };
+            this.db.exec (this.sql.make (SQLString.SAVE_RESOURCE), values);
+        }
+    }
+
+    private void update_guarded_object (MediaObject object) throws Error {
+        int type = ObjectType.CONTAINER;
+        GLib.Value parent;
+
+        if (object is Rygel.ODID.MediaItem) {
+            type = ObjectType.ITEM;
+        }
+
+        if (object.parent == null) {
+            parent = Database.@null ();
+        } else {
+            parent = object.parent.id;
+        }
+
+        GLib.Value[] values = { type,
+                                parent,
+                                object.upnp_class,
+                                object.date,
+                                object.creator,
+                                object.modified,
+                                object.uris.is_empty ? null : object.uris[0],
+                                object.object_update_id,
+                                -1,
+                                -1,
+                                object.id
+                              };
+        if (object is MediaContainer) {
+            var container = object as MediaContainer;
+            values[6] = container.total_deleted_child_count;
+            values[7] = container.update_id;
+        }
+
+        this.db.exec (this.sql.make (SQLString.UPDATE_GUARDED_OBJECT), values);
+    }
+
+    private void create_normal_object (MediaObject object,
+                                       bool is_guarded) throws Error {
+        int type = ObjectType.CONTAINER;
+        GLib.Value parent;
+
+        if (object is Rygel.ODID.MediaItem) {
+            type = ObjectType.ITEM;
+        }
+
+        if (object.parent == null) {
+            parent = Database.@null ();
+        } else {
+            parent = object.parent.id;
+        }
+
+        GLib.Value[] values = { object.id,
+                                object.title,
+                                type,
+                                parent,
+                                object.upnp_class,
+                                object.date,
+                                object.creator,
+                                object.modified,
+                                object.uris.is_empty ? null : object.uris[0],
+                                object.object_update_id,
+                                -1,
+                                -1,
+                                is_guarded ? 1 : 0,
+                                object.ref_id ?? null
+                              };
+        if (object is MediaContainer) {
+            var container = object as MediaContainer;
+            values[8] = container.total_deleted_child_count;
+            values[9] = container.update_id;
+        }
+
+        this.db.exec (this.sql.make (SQLString.INSERT), values);
+    }
+
+    /**
+     * Add the container or item to the cache.
+     */
+    private void create_object (MediaObject object,
+                                bool override_guarded = false) throws Error {
+        var is_guarded = this.is_object_guarded (object.id);
+
+        if (!override_guarded && is_guarded) {
+            update_guarded_object (object);
+        } else {
+            create_normal_object (object, (is_guarded || override_guarded));
+        }
+    }
+
+    /**
+     * Create the current schema.
+     *
+     * If schema creation fails, schema will be rolled back
+     * completely.
+     *
+     * @returns: true on success, false on failure
+     */
+    private bool create_schema () {
+        try {
+            db.begin ();
+            db.exec (this.sql.make (SQLString.SCHEMA));
+            db.exec (this.sql.make (SQLString.TRIGGER_COMMON));
+            db.exec (this.sql.make (SQLString.TABLE_CLOSURE));
+            db.exec (this.sql.make (SQLString.INDEX_COMMON));
+            db.exec (this.sql.make (SQLString.TRIGGER_CLOSURE));
+            db.exec (this.sql.make (SQLString.TRIGGER_REFERENCE));
+            db.commit ();
+            db.analyze ();
+            this.save_reset_token (UUID.get ());
+
+            return true;
+        } catch (Error err) {
+            warning ("Failed to create schema: %s", err.message);
+            db.rollback ();
+        }
+
+        return false;
+   }
+
+    /**
+     * Create a new container or item based on a SQL result.
+     *
+     * The Rygel server discards the object when the browse request is finished,
+     * after serializing the result.
+     *
+     * @param parent The object's parent container.
+     * @param statement a SQLite result indicating the container's details.
+     */
+    private MediaObject? get_object_from_statement (MediaContainer? parent,
+                                                    Statement       statement) {
+        MediaObject object = null;
+        unowned string title = statement.column_text (ObjectColumn.TITLE);
+        unowned string object_id = statement.column_text (ObjectColumn.ID);
+        unowned string uri = statement.column_text (ObjectColumn.URI);
+
+        switch (statement.column_int (ObjectColumn.TYPE)) {
+            case 0:
+                // this is a container
+                object = factory.get_container (object_id, title, 0, uri);
+                var container = object as MediaContainer;
+                if (uri != null) {
+                    container.uris.add (uri);
+                }
+                container.total_deleted_child_count = (uint32) statement.column_int64
+                                        (ObjectColumn.DELETED_CHILD_COUNT);
+                container.update_id = (uint) statement.column_int64
+                                        (ObjectColumn.CONTAINER_UPDATE_ID);
+                break;
+            case 1:
+                // this is an item
+                unowned string upnp_class = statement.column_text
+                                        (ObjectColumn.CLASS);
+                object = factory.get_item (parent,
+                                           object_id,
+                                           title,
+                                           upnp_class);
+
+                if (uri != null) {
+                    (object as Rygel.ODID.MediaItem).add_uri (uri);
+                }
+
+                fill_item (statement, object as Rygel.ODID.MediaItem);
+
+                break;
+            default:
+                assert_not_reached ();
+        }
+
+        if (object != null) {
+            object.modified = statement.column_int64 (ObjectColumn.TIMESTAMP);
+            if (object.modified  == int64.MAX && object is Rygel.ODID.MediaItem) {
+                object.modified = 0;
+            }
+            object.object_update_id = (uint) statement.column_int64
+                                        (ObjectColumn.OBJECT_UPDATE_ID);
+            object.ref_id = statement.column_text (ObjectColumn.REFERENCE_ID);
+        }
+
+        return object;
+    }
+
+    private void fill_item (Statement statement, Rygel.ODID.MediaItem item) {
+        // Fill common properties and resources from the database
+        item.date = statement.column_text (ObjectColumn.DATE);
+        item.creator = statement.column_text (ObjectColumn.CREATOR);
+        GLib.Value[] values = { item.id };
+
+        var resources_list = item.get_resource_list ();
+
+        try {
+            var cursor = this.exec_cursor (SQLString.GET_RESOURCES_BY_OBJECT,    
+                                              values);
+
+            foreach (var resource_stmt in cursor) {
+                MediaResource res = new MediaResource (resource_stmt.column_text (ResourceColumn.NAME));
+
+                var pi = new ProtocolInfo.from_string (resource_stmt.column_text
+                                                          (ResourceColumn.PROTOCOL_INFO));
+                res.set_protocol_info (pi);
+
+                res.duration        = resource_stmt.column_int (ResourceColumn.DURATION);
+                res.width           = resource_stmt.column_int (ResourceColumn.WIDTH);
+                res.height          = resource_stmt.column_int (ResourceColumn.HEIGHT);
+                res.bitrate         = resource_stmt.column_int (ResourceColumn.BITRATE);
+                res.sample_freq     = resource_stmt.column_int (ResourceColumn.SAMPLE_FREQ);
+                res.bits_per_sample = resource_stmt.column_int (ResourceColumn.BITS_PER_SAMPLE);
+                res.audio_channels  = resource_stmt.column_int (ResourceColumn.CHANNELS);
+                res.uri             = resource_stmt.column_text (ResourceColumn.EXTERNAL_URI);
+                res.extension       = resource_stmt.column_text (ResourceColumn.EXTENSION);
+                res.color_depth     = resource_stmt.column_int (ResourceColumn.COLOR_DEPTH);
+                res.size            = resource_stmt.column_int64 (ResourceColumn.SIZE);
+                res.cleartext_size  = resource_stmt.column_int64 (ResourceColumn.CLEARTEXT_SIZE);
+
+                resources_list.add (res);
+            }
+
+        } catch (Error error) {
+            warning ("Failed to get update ids: %s", error.message);
+        }
+    }
+
+    private static string translate_search_expression
+                                        (SearchExpression? expression,
+                                         ValueArray        args,
+                                         string            prefix = "WHERE")
+                                         throws Error {
+        if (expression == null) {
+            return "";
+        }
+
+        var filter = MediaCache.search_expression_to_sql (expression, args);
+
+        return " %s %s".printf (prefix, filter);
+    }
+
+    private static string? search_expression_to_sql
+                                        (SearchExpression? expression,
+                                         GLib.ValueArray   args)
+                                         throws Error {
+        if (expression == null) {
+            return "";
+        }
+
+        if (expression is LogicalExpression) {
+            return MediaCache.logical_expression_to_sql
+                                        (expression as LogicalExpression, args);
+        } else {
+            return MediaCache.relational_expression_to_sql
+                                        (expression as RelationalExpression,
+                                         args);
+        }
+    }
+
+    private static string logical_expression_to_sql
+                                        (LogicalExpression expression,
+                                         GLib.ValueArray   args)
+                                         throws Error {
+        string left_sql_string = MediaCache.search_expression_to_sql
+                                        (expression.operand1,
+                                         args);
+        string right_sql_string = MediaCache.search_expression_to_sql
+                                        (expression.operand2,
+                                         args);
+        unowned string operator_sql_string = "OR";
+
+        if (expression.op == LogicalOperator.AND) {
+            operator_sql_string = "AND";
+        }
+
+        return "(%s %s %s)".printf (left_sql_string,
+                                    operator_sql_string,
+                                    right_sql_string);
+    }
+
+    private static string? map_operand_to_column (string     operand,
+                                                  out string? collate = null,
+                                                  bool        for_sort = false)
+                                                  throws Error {
+        string column = null;
+        bool use_collation = false;
+
+        switch (operand) {
+            case "res":
+                column = "o.uri";
+                break;
+            case "res@duration":
+                column = "r.duration";
+                break;
+            case "@refID":
+                column = "o.reference_id";
+                break;
+            case "@id":
+                column = "o.upnp_id";
+                break;
+            case "@parentID":
+                column = "o.parent";
+                break;
+            case "upnp:class":
+                column = "o.class";
+                break;
+            case "dc:title":
+                column = "o.title";
+                use_collation = true;
+                break;
+            case "upnp:artist":
+            case "upnp:author":
+                column = "r.author";
+                use_collation = true;
+                break;
+            case "dc:creator":
+                column = "o.creator";
+                use_collation = true;
+                break;
+            case "dc:date":
+                if (for_sort) {
+                    column = "o.date";
+                } else {
+                    column = "strftime(\"%Y\", o.date)";
+                }
+                break;
+            case "upnp:album":
+                column = "r.album";
+                use_collation = true;
+                break;
+            case "upnp:genre":
+            case "dc:genre":
+                // FIXME: Remove dc:genre, upnp:genre is the correct one
+                column = "r.genre";
+                use_collation = true;
+                break;
+            case "upnp:originalTrackNumber":
+                column = "r.track";
+                break;
+            case "rygel:originalVolumeNumber":
+                column = "r.disc";
+                break;
+            case "upnp:objectUpdateID":
+                column = "o.object_update_id";
+                break;
+            case "upnp:containerUpdateID":
+                column = "o.container_update_id";
+                break;
+            default:
+                var message = "Unsupported column %s".printf (operand);
+
+                throw new MediaCacheError.UNSUPPORTED_SEARCH (message);
+        }
+
+        if (use_collation) {
+            collate = "COLLATE CASEFOLD";
+        } else {
+            collate = "";
+        }
+
+        return column;
+    }
+
+    private static string? relational_expression_to_sql
+                                        (RelationalExpression exp,
+                                         GLib.ValueArray      args)
+                                         throws Error {
+        GLib.Value? v = null;
+        string collate = null;
+
+        string column = MediaCache.map_operand_to_column (exp.operand1,
+                                                          out collate);
+        SqlOperator operator;
+
+        switch (exp.op) {
+            case SearchCriteriaOp.EXISTS:
+                string sql_function;
+                if (exp.operand2 == "true") {
+                    sql_function = "%s IS NOT NULL AND %s != ''";
+                } else {
+                    sql_function = "%s IS NULL OR %s = ''";
+                }
+
+                return sql_function.printf (column, column);
+            case SearchCriteriaOp.EQ:
+            case SearchCriteriaOp.NEQ:
+            case SearchCriteriaOp.LESS:
+            case SearchCriteriaOp.LEQ:
+            case SearchCriteriaOp.GREATER:
+            case SearchCriteriaOp.GEQ:
+                if (column == "o.class" &&
+                    exp.op == SearchCriteriaOp.EQ &&
+                    exp.operand2 == "object.container") {
+                    operator = new SqlOperator ("=", "o.type_fk");
+                    v = (int) ObjectType.CONTAINER;
+                } else {
+                    v = exp.operand2;
+                    operator = new SqlOperator.from_search_criteria_op
+                                            (exp.op, column, collate);
+                }
+                break;
+            case SearchCriteriaOp.CONTAINS:
+                operator = new SqlFunction ("contains", column);
+                v = exp.operand2;
+                break;
+            case SearchCriteriaOp.DOES_NOT_CONTAIN:
+                operator = new SqlFunction ("NOT contains", column);
+                v = exp.operand2;
+                break;
+            case SearchCriteriaOp.DERIVED_FROM:
+                if (column == "o.class" &&
+                    exp.operand2.has_prefix ("object.container")) {
+                    operator = new SqlOperator ("=", "o.type_fk");
+                    v = (int) ObjectType.CONTAINER;
+                } else {
+                    operator = new SqlOperator ("LIKE", column);
+                    v = "%s%%".printf (exp.operand2);
+                }
+                break;
+            default:
+                warning ("Unsupported op %d", exp.op);
+                return null;
+        }
+
+        if (v != null) {
+            args.append (v);
+        }
+
+        return operator.to_string ();
+    }
+
+    private DatabaseCursor exec_cursor (SQLString      id,
+                                        GLib.Value[]?  values = null)
+                                        throws DatabaseError {
+        return this.db.exec_cursor (this.sql.make (id), values);
+    }
+
+    private int query_value (SQLString      id,
+                             GLib.Value[]?  values = null)
+                             throws DatabaseError {
+        return this.db.query_value (this.sql.make (id), values);
+    }
+
+    private static string translate_sort_criteria (string sort_criteria) {
+        string? collate;
+        var builder = new StringBuilder ("ORDER BY ");
+        var fields = sort_criteria.split (",");
+        foreach (unowned string field in fields) {
+            try {
+                var column = MediaCache.map_operand_to_column
+                                        (field[1:field.length],
+                                         out collate,
+                                         true);
+                if (field != fields[0]) {
+                    builder.append (",");
+                }
+                builder.append_printf ("%s %s %s ",
+                                       column,
+                                       collate,
+                                       field[0] == '-' ? "DESC" : "ASC");
+            } catch (Error error) {
+                warning ("Skipping unsupported field: %s", field);
+            }
+        }
+
+        return builder.str;
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-media-item.vala b/src/plugins/odid/rygel-odid-media-item.vala
new file mode 100644
index 0000000..d8f864a
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-media-item.vala
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2012 Intel Corporation.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <jensg@openismus.com>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using GUPnP;
+
+public class Rygel.ODID.MediaItem : Rygel.MediaItem,
+                                    Rygel.UpdatableObject,
+                                    Rygel.ODID.UpdatableObject,
+                                    Rygel.TrackableItem {
+    public MediaItem (string         id,
+                      MediaContainer parent,
+                      string         title,
+                      string         upnp_class = Rygel.VideoItem.UPNP_CLASS) {
+        Object (id : id,
+                parent : parent,
+                title : title,
+                upnp_class : upnp_class);
+    }
+
+    public void add_uri (string uri) {
+        this.uris.add (uri);
+    }
+
+    public async void commit () throws Error {
+        yield this.commit_custom (true);
+    }
+
+    public async void commit_custom (bool override_guarded) throws Error {
+        this.changed ();
+        var cache = MediaCache.get_default ();
+        cache.save_item (this, override_guarded);
+    }
+
+    internal override DIDLLiteObject? serialize (Serializer serializer,
+                                                 HTTPServer http_server)
+                                                 throws Error {
+        var didl_item = base.serialize (serializer, http_server) as DIDLLiteItem;
+
+        serialize_resource_list (didl_item, http_server);
+
+        return didl_item;
+    }
+
+    public override DataSource? create_stream_source_for_resource
+                                    (HTTPRequest request, MediaResource resource) {
+        if (this.uris.size == 0) {
+            return null;
+        }
+
+        return MediaEngine.get_default ().create_data_source_for_resource
+                                              (this, resource);
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-node-query-container.vala b/src/plugins/odid/rygel-odid-node-query-container.vala
new file mode 100644
index 0000000..d714575
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-node-query-container.vala
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ * 
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+internal class Rygel.ODID.NodeQueryContainer : QueryContainer {
+    public string template { private get; construct; }
+    public string attribute { private get; construct; }
+
+    public NodeQueryContainer (SearchExpression expression,
+                               string           id,
+                               string           name,
+                               string           template,
+                               string           attribute) {
+        Object (id : id,
+                title : name,
+                parent : null,
+                child_count : 0,
+                expression : expression,
+                template : template,
+                attribute : attribute);
+    }
+
+    // MediaContainer overrides
+
+    public override async MediaObjects? get_children
+                                        (uint         offset,
+                                         uint         max_count,
+                                         string       sort_criteria,
+                                         Cancellable? cancellable)
+                                         throws GLib.Error {
+        var children = new MediaObjects ();
+        var factory = QueryContainerFactory.get_default ();
+
+        if (this.add_all_container ()) {
+            var id = this.template.replace (",upnp:album,%s","");
+            var container = factory.create_from_description_id (id,
+                                                                _("All"));
+            container.parent = this;
+            children.add (container);
+        }
+
+        var data = this.media_db.get_object_attribute_by_search_expression
+                                        (this.attribute,
+                                         this.expression,
+                                         // sort criteria
+                                         offset,
+                                         max_count);
+
+        foreach (var meta_data in data) {
+            var new_id = Uri.escape_string (meta_data, "", true);
+            // template contains URL escaped text. This means it might
+            // contain '%' chars which will makes sprintf crash
+            new_id = this.template.replace ("%s", new_id);
+            var container = factory.create_from_description_id (new_id,
+                                                                meta_data);
+            container.parent = this;
+            children.add (container);
+        }
+
+        return children;
+    }
+
+    // DBContainer overrides
+
+    public override int count_children () {
+        try {
+            var data = this.media_db.get_object_attribute_by_search_expression
+                                        (this.attribute,
+                                         this.expression,
+                                         0,
+                                         -1);
+            if (this.add_all_container ()) {
+                return data.size + 1;
+            }
+
+            return data.size;
+        } catch (Error error) {
+            return 0;
+        }
+    }
+
+    private bool add_all_container () {
+        return this.attribute == "upnp:album" &&
+               "upnp:artist" in this.template;
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-null-container.vala b/src/plugins/odid/rygel-odid-null-container.vala
new file mode 100644
index 0000000..aacc227
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-null-container.vala
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2009 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ * 
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Rygel;
+using Gee;
+
+/**
+ * This is an empty container used to satisfy Rygel if no mediadb could be
+ * created, or for light-weight hierarchies where the intermediate levels
+ * are not used.
+ */
+internal class Rygel.NullContainer : MediaContainer {
+    public NullContainer (string          id,
+                          MediaContainer? parent,
+                          string          title) {
+        Object (id : id,
+                title : title,
+                parent : parent,
+                child_count : 0);
+    }
+
+    public NullContainer.root () {
+        Object (id : "0",
+                parent : null,
+                title : "ODID",
+                child_count : 0);
+    }
+
+    public override async MediaObjects? get_children (
+                                                     uint         offset,
+                                                     uint         max_count,
+                                                     string       sort_criteria,
+                                                     Cancellable? cancellable)
+                                                     throws Error {
+        return new MediaObjects ();
+    }
+
+    public override async MediaObject? find_object (string id,
+                                                    Cancellable? cancellable)
+                                                    throws Error {
+        return null;
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-object-factory.vala b/src/plugins/odid/rygel-odid-object-factory.vala
new file mode 100644
index 0000000..c18b405
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-object-factory.vala
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+/**
+ * A helper class to create container and item
+ * instances based on ODID object IDs,
+ * sometimes delegating to the QueryContainerFactory.
+ */
+internal class Rygel.ODID.ObjectFactory : Object {
+    /**
+     * Return a new instance of DBContainer.
+     *
+     * @param media_db instance of MediaDB
+     * @param title title of the container
+     * @param child_count number of children in the container
+     */
+    public virtual DBContainer get_container (string     id,
+                                              string     title,
+                                              uint       child_count,
+                                              string?    uri) {
+        if (id == "0") {
+            return RootContainer.get_instance ();
+        } else if (id == RootContainer.FILESYSTEM_FOLDER_ID) {
+            var root_container = RootContainer.get_instance ();
+
+            return root_container.get_filesystem_container ();
+        }
+
+        if (id.has_prefix (QueryContainer.PREFIX)) {
+            var factory = QueryContainerFactory.get_default ();
+            return factory.create_from_hashed_id (id, title);
+        }
+
+        // Return a suitable container for the top-level virtual folders.
+        // This corresponds to the short-lived NullContainers that
+        // we used to save these in the database.
+        if (id.has_prefix ("virtual-parent:")) {
+            return new DBContainer (id, title);
+        }
+
+        if (uri == null) {
+            return new TrackableDbContainer (id, title);
+        }
+
+        // Return a writable container for anything with a URI,
+        // such as child folders of the file system,
+        // to allow uploads.
+        // See https://bugzilla.gnome.org/show_bug.cgi?id=676379 to
+        // give more control over this.
+        return new WritableDbContainer (id, title);
+    }
+
+    /**
+     * Return a new instance of MediaItem
+     *
+     * @param media_db instance of MediaDB
+     * @param id id of the item
+     * @param title title of the item
+     * @param upnp_class upnp_class of the item
+     */
+    public virtual MediaItem get_item (MediaContainer parent,
+                                       string         id,
+                                       string         title,
+                                       string         upnp_class) {
+        return new Rygel.ODID.MediaItem (id, parent, title, upnp_class);
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-playlist-container.vala b/src/plugins/odid/rygel-odid-playlist-container.vala
new file mode 100644
index 0000000..eb754b5
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-playlist-container.vala
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2013 Intel Corporation.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ * 
+ * Author: 
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+internal class Rygel.ODID.PlaylistContainer : DBContainer,
+                                                     Rygel.WritableContainer {
+    internal static const string URI = WritableContainer.WRITABLE_SCHEME +
+                                       "playlist-container";
+    public ArrayList<string> create_classes { get; set; }
+
+    public PlaylistContainer (string id, string title) {
+        Object (id : id,
+                title : title,
+                parent : null,
+                child_count : 0);
+    }
+
+    public override void constructed () {
+        base.constructed ();
+
+        this.upnp_class = Rygel.MediaContainer.PLAYLIST;
+        this.create_classes = new ArrayList<string> ();
+        // Only items, no folders
+        this.create_classes.add (Rygel.ImageItem.UPNP_CLASS);
+        this.create_classes.add (Rygel.PhotoItem.UPNP_CLASS);
+        this.create_classes.add (Rygel.VideoItem.UPNP_CLASS);
+        this.create_classes.add (Rygel.AudioItem.UPNP_CLASS);
+        this.create_classes.add (Rygel.MusicItem.UPNP_CLASS);
+
+        // Need to add an URI otherwise core doesn't mark the container as
+        // writable
+        this.uris.add (PlaylistContainer.URI);
+    }
+
+    public async void add_item (Rygel.ODID.MediaItem item,
+                                Cancellable?    cancellable)
+                                throws Error {
+        throw new WritableContainerError.NOT_IMPLEMENTED
+                                        (_("Can't create items in %s"),
+                                         this.id);
+    }
+
+    public virtual async string add_reference (MediaObject  object,
+                                               Cancellable? cancellable)
+                                               throws Error {
+        return MediaCache.get_default ().create_reference (object, this);
+    }
+
+    public async void remove_item (string id,
+                                   Cancellable?    cancellable)
+                                   throws Error {
+        this.media_db.remove_by_id (id);
+        this.updated ();
+    }
+
+    public async void add_container (Rygel.MediaContainer container,
+                                     Cancellable?         cancellable)
+                                     throws Error {
+        throw new WritableContainerError.NOT_IMPLEMENTED
+                                        (_("Can't add containers in %s"),
+                                         this.id);
+    }
+
+    public async void remove_container (string id,
+                                        Cancellable?    cancellable)
+                                        throws Error {
+        throw new WritableContainerError.NOT_IMPLEMENTED
+                                        (_("Can't remove containers in %s"),
+                                         this.id);
+    }
+
+}
diff --git a/src/plugins/odid/rygel-odid-playlist-root-container.vala b/src/plugins/odid/rygel-odid-playlist-root-container.vala
new file mode 100644
index 0000000..abb906f
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-playlist-root-container.vala
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2013 Intel Corporation.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: 
+ *         Doug Galligan <doug@sentosatech.com>
+ * 
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+/**
+ * Special class for the toplevel virtual container which aggregates all
+ * playlists.
+ *
+ * This is a special class compared to the the other virtual classes as it
+ * allows the creation of subfolders to create server-side playlists.
+ * It does not allow adding or removal of items, only container adding and
+ * removal.
+ */
+internal class Rygel.ODID.PlaylistRootContainer : Rygel.WritableContainer,
+                                                         DBContainer {
+    internal static const string ID = "virtual-parent:" +
+                                      Rygel.PlaylistItem.UPNP_CLASS;
+    internal static const string URI = WritableContainer.WRITABLE_SCHEME +
+                                       "playlist-root";
+    public ArrayList<string> create_classes { get; set; }
+
+    public PlaylistRootContainer () {
+        Object (id : ID,
+                title : _("Playlists"),
+                parent : null,
+                child_count : 0);
+    }
+
+    public override void constructed () {
+        base.constructed ();
+
+        // We don't support adding real folders here, just playlist container
+        this.create_classes = new ArrayList<string> ();
+        this.create_classes.add (Rygel.MediaContainer.UPNP_CLASS);
+
+        // Need to add an URI otherwise core doesn't mark the container as
+        // writable
+        this.uris.add (PlaylistRootContainer.URI);
+    }
+
+    public async void add_item (Rygel.MediaItem item,
+                                Cancellable?    cancellable)
+                                throws Error {
+        throw new WritableContainerError.NOT_IMPLEMENTED
+                                        (_("Can't create items in %s"),
+                                         this.id);
+    }
+
+    public async void remove_item (string id,
+                                   Cancellable? cancellable)
+                                   throws Error {
+        throw new WritableContainerError.NOT_IMPLEMENTED
+                                        (_("Can't remove items in %s"),
+                                         this.id);
+   }
+
+    public async void add_container (Rygel.MediaContainer container,
+                                     Cancellable?         cancellable)
+                                     throws Error {
+        if (container.upnp_class != Rygel.MediaContainer.PLAYLIST &&
+            container.upnp_class != Rygel.MediaContainer.UPNP_CLASS) {
+            throw new WritableContainerError.NOT_IMPLEMENTED
+                                        (_("upnp:class not supported in %s"),
+                                         this.id);
+        }
+
+        container.id = "playlist:" + UUID.get ();
+        container.upnp_class = Rygel.MediaContainer.PLAYLIST;
+
+        this.media_db.save_container (container);
+        this.media_db.make_object_guarded (container);
+    }
+
+    public async void remove_container (string id,
+                                        Cancellable?    cancellable)
+                                        throws Error {
+        this.media_db.remove_by_id (id);
+    }
+ }
diff --git a/src/plugins/odid/rygel-odid-plugin.vala b/src/plugins/odid/rygel-odid-plugin.vala
new file mode 100644
index 0000000..0902528
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-plugin.vala
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2008-2009 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Rygel;
+using GUPnP;
+
+private const string TRACKER_PLUGIN = "Tracker";
+
+/**
+ * Simple plugin which exposes the media contents of a directory via UPnP.
+ *
+ */
+public void module_init (PluginLoader loader) {
+    try {
+        // Instantiate the plugin object (it may fail if loading
+        // database did not succeed):
+        var plugin = new ODID.Plugin ();
+
+        // Check what other plugins are loaded,
+        // and check when other plugins are loaded later:
+        Idle.add (() => {
+           foreach (var loaded_plugin in loader.list_plugins ()) {
+                on_plugin_available (loaded_plugin, plugin);
+           }
+
+           loader.plugin_available.connect ((new_plugin) => {
+               on_plugin_available (new_plugin, plugin);
+           });
+
+           return false;
+        });
+
+        loader.add_plugin (plugin);
+    } catch (Error error) {
+        warning ("Failed to load %s: %s",
+                 ODID.Plugin.NAME,
+                 error.message);
+    }
+}
+
+public void on_plugin_available (Plugin plugin, Plugin our_plugin) {
+    // Do not allow this plugin and the tracker plugin to both be
+    // active at the same time,
+    // because they serve the same purpose.
+    if (plugin.name == TRACKER_PLUGIN) {
+        if (our_plugin.active && !plugin.active) {
+            // The Tracker plugin might be activated later,
+            // so shut this plugin down if that happens.
+            plugin.notify["active"].connect (() => {
+                if (plugin.active) {
+                    shutdown_media_export ();
+                    our_plugin.active = !plugin.active;
+                }
+            });
+        } else if (our_plugin.active == plugin.active) {
+            if (plugin.active) {
+                // The Tracker plugin is already active,
+                // so shut this plugin down immediately.
+                shutdown_media_export ();
+            } else {
+                // Log that we are starting this plugin
+                // because the Tracker plugin is not active instead.
+                message ("Plugin '%s' inactivate, activating '%s' plugin",
+                         TRACKER_PLUGIN,
+                         ODID.Plugin.NAME);
+            }
+            our_plugin.active = !plugin.active;
+        }
+    }
+}
+
+private void shutdown_media_export () {
+    message ("Deactivating plugin '%s' in favor of plugin '%s'",
+             ODID.Plugin.NAME,
+             TRACKER_PLUGIN);
+    try {
+        var config = MetaConfig.get_default ();
+        var enabled = config.get_bool (ODID.Plugin.NAME, "enabled");
+        if (enabled) {
+            var root = Rygel.ODID.RootContainer.get_instance ();
+
+            root.shutdown ();
+        }
+    } catch (Error error) {};
+}
+
+public class Rygel.ODID.Plugin : Rygel.MediaServerPlugin {
+    public const string NAME = "ODIDMediaServer";
+
+    /**
+     * Instantiate the plugin.
+     */
+    public Plugin () throws Error {
+        // Ensure that root container could be created and thus
+        // database could be opened:
+        RootContainer.ensure_exists ();
+        // Call the base constructor,
+        // passing the instance of our root container.
+        base (RootContainer.get_instance (),
+              NAME,
+              null,
+              PluginCapabilities.UPLOAD |
+              PluginCapabilities.TRACK_CHANGES);
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-query-container-factory.vala b/src/plugins/odid/rygel-odid-query-container-factory.vala
new file mode 100644
index 0000000..a984472
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-query-container-factory.vala
@@ -0,0 +1,276 @@
+/*
+ * Copyright (C) 2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+using GUPnP;
+
+/**
+ * A helper class to create QueryContainer instances based on IDs.
+ */
+internal class Rygel.ODID.QueryContainerFactory : Object {
+    // private static members
+    private static QueryContainerFactory instance;
+
+    // private members
+    private HashMap<string,string> virtual_container_map;
+
+    // public static functions
+    public static QueryContainerFactory get_default () {
+        if (unlikely (instance == null)) {
+            instance = new QueryContainerFactory ();
+        }
+
+        return instance;
+    }
+
+    // constructors
+    private QueryContainerFactory () {
+        this.virtual_container_map = new HashMap<string, string> ();
+    }
+
+    // public functions
+
+    /**
+     * Register a plaintext description for a query container. The passed
+     * string will be modified to the checksum id of the container.
+     *
+     * @param id Originally contains the plaintext id which is replaced with
+     *           the hashed id on return.
+     */
+    public void register_id (ref string id) {
+        var md5 = Checksum.compute_for_string (ChecksumType.MD5, id);
+
+        if (!this.virtual_container_map.has_key (md5)) {
+            this.virtual_container_map[md5] = id;
+            debug ("Registering %s for %s", md5, id);
+        }
+
+        id = QueryContainer.PREFIX + md5;
+    }
+
+    /**
+     * Get the plaintext definition from a hashed id.
+     *
+     * Inverse function of register_id().
+     *
+     * @param hash A hashed id
+     * @return the plaintext defintion of the virtual folder
+     */
+    public string? get_virtual_container_definition (string hash) {
+        var id = hash.replace (QueryContainer.PREFIX, "");
+
+        return this.virtual_container_map[id];
+    }
+
+    /**
+     * Factory method.
+     *
+     * Create a QueryContainer directly from MD5 hashed id.
+     *
+     * @param id    The hashed id of the container
+     * @param name  An the title of the container. If not supplied, it will
+     *              be derived from the plain-text description of the
+     *              container
+     * @return A new instance of QueryContainer or null if id does not exist
+     */
+    public QueryContainer? create_from_hashed_id (string id,
+                                                  string name = "") {
+        var definition_id = this.get_virtual_container_definition (id);
+        if (definition_id == null) {
+            return null;
+        }
+
+        return this.create_from_description_id (definition_id, name);
+    }
+
+    /**
+     * Factory method.
+     *
+     * Create a QueryContainer from a plain-text description string.
+     *
+     * @param definition Plain-text defintion of the query-container
+     * @param name       The title of the container. If not supplied, it
+     *                   will be derived from the plain-text description of
+     *                   the container
+     * @return A new instance of QueryContainer
+     */
+    public QueryContainer create_from_description_id (string definition_id,
+                                                      string name = "") {
+        var title = name;
+        string attribute = null;
+        string pattern = null;
+        string upnp_class = null;
+        QueryContainer container;
+
+        var id = definition_id;
+        this.register_id (ref id);
+
+        var expression = QueryContainerFactory.parse_description
+                                        (definition_id,
+                                         out pattern,
+                                         out attribute,
+                                         out upnp_class,
+                                         ref title);
+
+        // Create a node or leaf container,
+        // depending on whether the definition specifies a pattern.
+        if (pattern == null || pattern == "") {
+            container =  new LeafQueryContainer (expression,
+                                                 id,
+                                                 title);
+        } else {
+            container = new NodeQueryContainer (expression,
+                                                id,
+                                                title,
+                                                pattern,
+                                                attribute);
+        }
+
+        if (upnp_class != null) {
+            container.upnp_class = upnp_class;
+            if (upnp_class == MediaContainer.MUSIC_ALBUM) {
+                container.sort_criteria = MediaContainer.ALBUM_SORT_CRITERIA;
+            }
+        }
+
+        return container;
+    }
+
+    // private methods
+
+    /**
+     * Map a DIDL attribute to a UPnP container class.
+     *
+     * @return A matching UPnP class for the attribute or null if it can't be
+     *         mapped.
+     */
+    private static string? map_upnp_class (string attribute) {
+        switch (attribute) {
+            case "upnp:album":
+                return MediaContainer.MUSIC_ALBUM;
+            case "dc:creator":
+            case "upnp:artist":
+                return MediaContainer.MUSIC_ARTIST;
+            case "dc:genre":
+                return MediaContainer.MUSIC_GENRE;
+            default:
+                return null;
+        }
+    }
+
+    /**
+     * Parse a plaintext container description into a search expression.
+     *
+     * Also generates a name for the container and other meta-data necessary
+     * for node containers.
+     *
+     * @param description The plaintext container description
+     * @param pattern     Contains the pattern used for child containers if
+     *                    descrption is for a node container, null otherwise.
+     * @param attribute   Contains the UPnP attribute the container describes.
+     * @param name        If passed empty, name will be generated from the
+     *                    description.
+     * @return A SearchExpression corresponding to the non-variable part of
+     *         the description.
+     */
+    private static SearchExpression parse_description (string     description,
+                                                       out string pattern,
+                                                       out string attribute,
+                                                       out string upnp_class,
+                                                       ref string name) {
+        var args = description.split (",");
+        var expression = null as SearchExpression;
+        pattern = null;
+        attribute = null;
+        upnp_class = null;
+
+        int i = 0;
+        while (i < args.length) {
+            string previous_attribute = attribute;
+
+            attribute = args[i].replace (QueryContainer.PREFIX, "");
+            attribute = Uri.unescape_string (attribute);
+
+            if (args[i + 1] != "?") {
+                QueryContainerFactory.update_search_expression (ref expression,
+                                                                args[i],
+                                                                args[i + 1]);
+
+                // We're on the end of the list, map UPnP class
+                if (i + 2 == args.length) {
+                    upnp_class = QueryContainerFactory.map_upnp_class
+                                        (attribute);
+                }
+            } else {
+                args[i + 1] = "%s";
+                pattern = string.joinv (",", args);
+
+                // This container has the previouss attribute's content, so
+                // use that to map the UPnP class.
+                upnp_class = QueryContainerFactory.map_upnp_class
+                                        (previous_attribute);
+
+                if (name == "" && i > 0) {
+                    name = Uri.unescape_string (args[i - 1]);
+                }
+
+                break;
+            }
+
+            i += 2;
+        }
+
+        return expression;
+    }
+
+    /**
+     * Update a SearchExpression with a new key = value condition.
+     *
+     * Will modifiy the passed expression to (expression AND (key = value))
+     *
+     * @param expression The expression to update or null to create a new one
+     * @param key        Key of the key/value condition
+     * @param value      Value of the key/value condition
+     */
+    private static void update_search_expression
+                                        (ref SearchExpression? expression,
+                                         string                key,
+                                         string                @value) {
+        var subexpression = new RelationalExpression ();
+        var clean_key = key.replace (QueryContainer.PREFIX, "");
+        subexpression.operand1 = Uri.unescape_string (clean_key);
+        subexpression.op = SearchCriteriaOp.EQ;
+        subexpression.operand2 = Uri.unescape_string (@value);
+
+        if (expression != null) {
+            var conjunction = new LogicalExpression ();
+            conjunction.operand1 = expression;
+            conjunction.operand2 = subexpression;
+            conjunction.op = LogicalOperator.AND;
+            expression = conjunction;
+        } else {
+            expression = subexpression;
+        }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-query-container.vala b/src/plugins/odid/rygel-odid-query-container.vala
new file mode 100644
index 0000000..26cedf3
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-query-container.vala
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2009,2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+using GUPnP;
+
+internal abstract class Rygel.ODID.QueryContainer : DBContainer {
+    // public static members
+    public static const string PREFIX = "virtual-container:";
+    public static const string ITEM_PREFIX = "virtual-id:";
+
+    // public members
+    public SearchExpression expression { get; construct set; }
+
+    // constructors
+    public QueryContainer (SearchExpression expression,
+                           string           id,
+                           string           name) {
+        Object (id : id,
+                parent : null,
+                title : name,
+                child_count : 0,
+                expression : expression);
+    }
+
+    // public methods
+    public async override MediaObjects? search (SearchExpression? expression,
+                                                uint              offset,
+                                                uint              max_count,
+                                                out uint          total_matches,
+                                                string            sort_criteria,
+                                                Cancellable?      cancellable)
+                                                throws GLib.Error {
+        MediaObjects children = null;
+
+        SearchExpression combined_expression;
+
+        if (expression == null) {
+            combined_expression = this.expression;
+        } else {
+            var local_expression = new LogicalExpression ();
+            local_expression.operand1 = this.expression;
+            local_expression.op = LogicalOperator.AND;
+            local_expression.operand2 = expression;
+            combined_expression = local_expression;
+        }
+
+        try {
+            children = this.media_db.get_objects_by_search_expression
+                                        (combined_expression,
+                                         null,
+                                         sort_criteria,
+                                         offset,
+                                         max_count,
+                                         out total_matches);
+        } catch (MediaCacheError error) {
+            if (error is MediaCacheError.UNSUPPORTED_SEARCH) {
+                children = new MediaObjects ();
+                total_matches = 0;
+            } else {
+                throw error;
+            }
+        }
+
+        return children;
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-recursive-file-monitor.vala b/src/plugins/odid/rygel-odid-recursive-file-monitor.vala
new file mode 100644
index 0000000..dbcb864
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-recursive-file-monitor.vala
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2009 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+public class Rygel.ODID.RecursiveFileMonitor : Object {
+    private Cancellable        cancellable;
+    HashMap<File, FileMonitor> monitors;
+    bool                       monitor_changes;
+
+    public RecursiveFileMonitor (Cancellable? cancellable) {
+        this.monitor_changes = true;
+        var config = MetaConfig.get_default ();
+        config.setting_changed.connect (this.on_config_changed);
+        this.on_config_changed (config, Plugin.NAME, "monitor-changes");
+
+        if (!this.monitor_changes) {
+            message (_("Will not monitor file changes"));
+        }
+
+        this.cancellable = cancellable;
+        this.monitors = new HashMap<File, FileMonitor> ((HashDataFunc<File>) File.hash,
+                                                        (EqualDataFunc<File>) File.equal);
+        if (cancellable != null) {
+            cancellable.cancelled.connect (this.cancel);
+        }
+    }
+
+    public void on_monitor_changed (File             file,
+                                    File?            other_file,
+                                    FileMonitorEvent event_type) {
+        if (this.monitor_changes) {
+            this.changed (file, other_file, event_type);
+        }
+
+        switch (event_type) {
+            case FileMonitorEvent.CREATED:
+                this.add.begin (file);
+
+                break;
+            case FileMonitorEvent.DELETED:
+                var file_monitor = this.monitors.get (file);
+                if (file_monitor != null) {
+                    debug ("Folder %s gone; removing watch",
+                           file.get_uri ());
+                    this.monitors.unset (file);
+                    file_monitor.cancel ();
+                    file_monitor.changed.disconnect (this.on_monitor_changed);
+                }
+
+                break;
+            default:
+                // do nothing
+                break;
+        }
+    }
+
+    public async void add (File file) {
+        if (this.monitors.has_key (file)) {
+            return;
+        }
+
+        try {
+            var info = yield file.query_info_async
+                                        (FileAttribute.STANDARD_TYPE,
+                                         FileQueryInfoFlags.NONE,
+                                         Priority.DEFAULT,
+                                         null);
+            if (info.get_file_type () == FileType.DIRECTORY) {
+                var file_monitor = file.monitor_directory
+                                        (FileMonitorFlags.NONE,
+                                         this.cancellable);
+                this.monitors.set (file, file_monitor);
+                file_monitor.changed.connect (this.on_monitor_changed);
+            }
+        } catch (Error err) {
+            warning (_("Failed to get file info for %s"), file.get_uri ());
+        }
+    }
+
+    public void cancel () {
+        foreach (var monitor in this.monitors.values) {
+            monitor.cancel ();
+        }
+
+        this.monitors.clear ();
+    }
+
+    public signal void changed (File             file,
+                                File?            other_file,
+                                FileMonitorEvent event_type);
+
+    private void on_config_changed (Configuration config,
+                                    string section,
+                                    string key) {
+        if (section != Plugin.NAME || key != "monitor-changes") {
+            return;
+        }
+
+        try {
+            this.monitor_changes = config.get_bool (Plugin.NAME,
+                                                    "monitor-changes");
+        } catch (Error error) { }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-root-container.vala b/src/plugins/odid/rygel-odid-root-container.vala
new file mode 100644
index 0000000..406aeb4
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-root-container.vala
@@ -0,0 +1,668 @@
+/*
+ * Copyright (C) 2009-2013 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+using GUPnP;
+
+internal struct Rygel.ODID.FolderDefinition {
+    string title;
+    string definition;
+}
+
+// Titles and definitions of some virtual folders,
+// for use with QueryContainer.
+const Rygel.ODID.FolderDefinition[] VIRTUAL_FOLDERS_DEFAULT = {
+    { N_("Year"), "dc:date,?" },
+    { N_("All"),  "" }
+};
+
+// Titles and definitions of virtual folders for Music,
+// for use with QueryContainer.
+const Rygel.ODID.FolderDefinition[] VIRTUAL_FOLDERS_MUSIC = {
+    { N_("Artist"), "upnp:artist,?,upnp:album,?" },
+    { N_("Album"),  "upnp:album,?" },
+    { N_("Genre"),  "dc:genre,?" }
+};
+
+/**
+ * Represents the root container.
+ */
+public class Rygel.ODID.RootContainer : TrackableDbContainer {
+    private Harvester      harvester;
+    private Cancellable    cancellable;
+    private DBContainer    filesystem_container;
+    private ulong          harvester_signal_id;
+    private ulong          filesystem_signal_id;
+
+    private static RootContainer instance = null;
+
+    internal const string FILESYSTEM_FOLDER_NAME = N_("Files & Folders");
+    internal const string FILESYSTEM_FOLDER_ID   = "Filesystem";
+
+    private const string SEARCH_CONTAINER_PREFIX = QueryContainer.PREFIX +
+                                                   "upnp:class," +
+                                                   Rygel.MusicItem.UPNP_CLASS +
+                                                   ",";
+
+    public static void ensure_exists () throws Error {
+        if (RootContainer.instance == null) {
+            MediaCache.ensure_exists ();
+            RootContainer.instance = new RootContainer ();
+        }
+    }
+
+    /**
+     * Get the single instance of the root container.
+     */
+    public static RootContainer get_instance () {
+        return RootContainer.instance;
+    }
+
+    public DBContainer get_filesystem_container () {
+        return this.filesystem_container;
+    }
+
+    public void shutdown () {
+        this.cancellable.cancel ();
+    }
+
+    // MediaContainer overrides
+
+    public override async MediaObject? find_object (string       id,
+                                                    Cancellable? cancellable)
+                                                    throws Error {
+        var object = yield base.find_object (id, cancellable);
+
+        if (object != null) {
+            return object;
+        }
+
+        if (id.has_prefix (QueryContainer.PREFIX)) {
+            var factory = QueryContainerFactory.get_default ();
+            var container = factory.create_from_hashed_id (id);
+            if (container != null) {
+                container.parent = this;
+            }
+
+            return container;
+        } else if (id.has_prefix (QueryContainer.ITEM_PREFIX)) {
+            var tmp_id = id.replace (QueryContainer.ITEM_PREFIX, "");
+            var parts = tmp_id.split (":");
+            if (parts.length != 2) {
+                return null;
+            }
+
+            object = yield base.find_object (parts[1], cancellable);
+
+            if (object == null) {
+                return null;
+            }
+
+            object.ref_id = object.id;
+            object.id = id;
+
+            var factory = QueryContainerFactory.get_default ();
+            var container_id = QueryContainer.PREFIX + parts[0];
+            var container = factory.create_from_hashed_id (container_id);
+            if (container == null) {
+                return null;
+            }
+
+            object.parent_ref = container;
+        }
+
+        return object;
+    }
+
+    public override async MediaObjects? search (SearchExpression? expression,
+                                                uint              offset,
+                                                uint              max_count,
+                                                out uint          total_matches,
+                                                string            sort_criteria,
+                                                Cancellable?      cancellable)
+                                                throws GLib.Error {
+        if (expression == null) {
+            return yield base.search (expression,
+                                      offset,
+                                      max_count,
+                                      out total_matches,
+                                      sort_criteria,
+                                      cancellable);
+        }
+
+        MediaObjects list;
+        MediaContainer query_container = null;
+        string upnp_class = null;
+
+        if (expression is RelationalExpression) {
+            var relational_expression = expression as RelationalExpression;
+
+            query_container = this.search_to_virtual_container
+                                        (relational_expression);
+            upnp_class = relational_expression.operand2;
+        } else if (is_search_in_virtual_container (expression,
+                                                   out query_container)) {
+            // do nothing. query_container is filled then
+        }
+
+        if (query_container != null) {
+            list = yield query_container.get_children (offset,
+                                                       max_count,
+                                                       sort_criteria,
+                                                       cancellable);
+            total_matches = query_container.child_count;
+
+            if (upnp_class != null) {
+                foreach (var object in list) {
+                    object.upnp_class = upnp_class;
+                }
+            }
+
+            return list;
+        } else {
+            return yield base.search (expression,
+                                      offset,
+                                      max_count,
+                                      out total_matches,
+                                      sort_criteria,
+                                      cancellable);
+        }
+    }
+
+    // Private methods
+
+    private ArrayList<File> get_shared_uris () {
+        ArrayList<string> uris;
+        ArrayList<File> actual_uris;
+
+        var config = MetaConfig.get_default ();
+
+        try {
+            uris = config.get_string_list (ODID.Plugin.NAME, "uris");
+        } catch (Error error) {
+            uris = new ArrayList<string> ();
+        }
+
+        actual_uris = new ArrayList<File> ((EqualDataFunc<File>) File.equal);
+
+        var home_dir = File.new_for_path (Environment.get_home_dir ());
+        unowned string pictures_dir = Environment.get_user_special_dir
+                                        (UserDirectory.PICTURES);
+        unowned string videos_dir = Environment.get_user_special_dir
+                                        (UserDirectory.VIDEOS);
+        unowned string music_dir = Environment.get_user_special_dir
+                                        (UserDirectory.MUSIC);
+
+        foreach (var uri in uris) {
+            var file = File.new_for_commandline_arg (uri);
+            if (likely (!file.equal (home_dir))) {
+                var actual_uri = uri;
+
+                if (likely (pictures_dir != null)) {
+                    actual_uri = actual_uri.replace ("@PICTURES@", pictures_dir);
+                }
+                if (likely (videos_dir != null)) {
+                    actual_uri = actual_uri.replace ("@VIDEOS@", videos_dir);
+                }
+                if (likely (music_dir != null)) {
+                    actual_uri = actual_uri.replace ("@MUSIC@", music_dir);
+                }
+
+                // protect against special directories expanding to $HOME
+                file = File.new_for_commandline_arg (actual_uri);
+                if (file.equal (home_dir)) {
+                    continue;
+                }
+            }
+
+            actual_uris.add (file);
+        }
+
+        return actual_uris;
+    }
+
+    private MediaContainer? search_to_virtual_container (
+                                       RelationalExpression expression) {
+        if (expression.operand1 == "upnp:class" &&
+            expression.op == SearchCriteriaOp.EQ) {
+            string id = SEARCH_CONTAINER_PREFIX;
+            switch (expression.operand2) {
+                case "object.container.album.musicAlbum":
+                    id += "upnp:album,?";
+
+                    break;
+                case "object.container.person.musicArtist":
+                    id += "dc:creator,?,upnp:album,?";
+
+                    break;
+                case "object.container.genre.musicGenre":
+                    id += "dc:genre,?";
+
+                    break;
+                default:
+                    return null;
+            }
+
+            var factory = QueryContainerFactory.get_default ();
+
+            return factory.create_from_description_id (id);
+        }
+
+        return null;
+    }
+
+    /**
+     * Check if a passed search expression is a simple search in a virtual
+     * container.
+     *
+     * @param expression the expression to check
+     * @param new_id contains the id of the virtual container constructed from
+     *               the search
+     * @param upnp_class contains the class of the container the search was
+     *                   looking in
+     * @return true if it was a search in virtual container, false otherwise.
+     * @note This works single level only. Enough to satisfy Xbox music
+     *       browsing, but may need refinement
+     */
+    private bool is_search_in_virtual_container (SearchExpression   expression,
+                                                 out MediaContainer container) {
+        RelationalExpression virtual_expression = null;
+        MediaContainer query_container;
+
+        container = null;
+
+        if (!(expression is LogicalExpression)) {
+            return false;
+        }
+
+        var logical_expression = expression as LogicalExpression;
+
+        if (!(logical_expression.operand1 is RelationalExpression &&
+            logical_expression.operand2 is RelationalExpression &&
+            logical_expression.op == LogicalOperator.AND)) {
+
+            return false;
+        }
+
+        var left_expression = logical_expression.operand1 as RelationalExpression;
+        var right_expression = logical_expression.operand2 as RelationalExpression;
+
+        query_container = search_to_virtual_container (left_expression);
+        if (query_container == null) {
+            query_container = search_to_virtual_container (right_expression);
+            if (query_container != null) {
+                virtual_expression = left_expression;
+            } else {
+                return false;
+            }
+        } else {
+            virtual_expression = right_expression;
+        }
+
+        var factory = QueryContainerFactory.get_default ();
+        var plaintext_id = factory.get_virtual_container_definition
+                                        (query_container.id);
+
+        var last_argument = plaintext_id.replace (QueryContainer.PREFIX, "");
+
+        var escaped_detail = Uri.escape_string (virtual_expression.operand2,
+                                                "",
+                                                true);
+        var new_id = "%s%s,%s,%s".printf (QueryContainer.PREFIX,
+                                          virtual_expression.operand1,
+                                          escaped_detail,
+                                          last_argument);
+
+        container = factory.create_from_description_id (new_id);
+
+        return true;
+    }
+
+
+    /**
+     * Create a new root container.
+     */
+    private RootContainer () {
+        Object (id : "0",
+                title : _("@REALNAME@'s media"),
+                parent : null,
+                child_count : 0);
+    }
+
+    private bool initialized = false;
+
+    public override uint32 get_system_update_id () {
+        // Call the base class implementation.
+        var id = base.get_system_update_id ();
+
+        // Now we may initialize our container.
+        // We waited until now to avoid changing
+        // the cached System Update ID before we 
+        // have provided it.
+        //
+        // We do this via an idle handler to avoid
+        // delaying Rygel's startup.
+        Idle.add (() => {
+            try {
+                this.init ();
+            } catch (Error error) { }
+
+            return false;
+        });
+
+        return id;
+    }
+
+    private void init () throws Error {
+        // This should only be called once.
+        if (this.initialized) {
+            return;
+        }
+
+        this.initialized = true;
+        this.cancellable = new Cancellable ();
+
+        // Tell the cache about this root container.
+        try {
+            this.media_db.save_container (this);
+        } catch (Error error) { } // do nothing
+
+        // Create a child container to serve the
+        // filesystem hierarchy, and tell the cache about it.
+        try {
+            this.filesystem_container = new TrackableDbContainer
+                                        (FILESYSTEM_FOLDER_ID,
+                                         _(FILESYSTEM_FOLDER_NAME));
+            this.filesystem_container.parent = this;
+            this.media_db.save_container (this.filesystem_container);
+        } catch (Error error) { }
+
+        // Get the top-level media items or containers known 
+        // to the cache from the last service run:
+        ArrayList<string> ids;
+        try {
+            ids = media_db.get_child_ids (FILESYSTEM_FOLDER_ID);
+        } catch (DatabaseError e) {
+            ids = new ArrayList<string> ();
+        }
+
+        // Instantiate the harvester to asynchronously discover
+        // media on the filesystem, providing our desired URIs to
+        // scan, and react when it has finished its initial scan.
+        // It adds all files it finds to the same media cache
+        // singleton instance that this class uses.
+        this.harvester = new Harvester (this.cancellable,
+                                        this.get_shared_uris ());
+        this.harvester_signal_id = this.harvester.done.connect
+                                        (on_initial_harvesting_done);
+
+        // For each location that we want the harvester to scan,
+        // remove it from the cache.
+        foreach (var file in this.harvester.locations) {
+            ids.remove (MediaCache.get_id (file.get_uri ()));
+        }
+
+        // Warn about any top-level locations that were known to 
+        // the cache (see above) but which we no longer care about,
+        // and remove it from the cache.
+        foreach (var id in ids) {
+            debug ("ID %s is no longer in the configuration. Deleting...", id);
+            try {
+                // FIXME: I think this needs to emit objDel events...
+                this.media_db.remove_by_id (id);
+            } catch (DatabaseError error) {
+                warning (_("Failed to remove entry: %s"), error.message);
+            }
+        }
+
+        // Before we start (re-)scanning, create a cache with all mtimes. This
+        // is done here in case we removed ids from above so we make sure we
+        // re-visit everything.
+        this.media_db.rebuild_exists_cache ();
+
+        // Request a rescan of all top-level locations.
+        this.harvester.schedule_locations (this.filesystem_container);
+
+        // We have removed some uris so we notify that the root container has
+        // changed
+        if (!ids.is_empty) {
+            this.root_updated ();
+        }
+
+        // Subscribe to configuration changes
+        MetaConfig.get_default ().setting_changed.connect
+                                        (this.on_setting_changed);
+    }
+
+    // Signal that the container has been updated with new/changed content.
+    private void root_updated () {
+        // Emit the "container-updated" signal
+        this.updated ();
+
+        // Persist the container_update_id value that was generated by
+        // the call to updated().
+        try {
+            this.media_db.save_container (this);
+        } catch (Error error) { }
+    }
+
+    private void on_setting_changed (string section, string key) {
+        if (section != Plugin.NAME) {
+            return;
+        }
+
+        if (key == "uris") {
+            this.handle_uri_config_change ();
+        } else if (key == "virtual-folders") {
+            this.handle_virtual_folder_change ();
+        }
+    }
+
+    private void handle_uri_config_change () {
+        var uris = this.get_shared_uris ();
+        // Calculate added uris
+        var new_uris = new ArrayList<File>
+                                    ((EqualDataFunc<File>) File.equal);
+        new_uris.add_all (uris);
+        new_uris.remove_all (harvester.locations);
+
+        // Calculate removed uris
+        var old_uris = new ArrayList<File>
+                                    ((EqualDataFunc<File>) File.equal);
+        old_uris.add_all (this.harvester.locations);
+        old_uris.remove_all (uris);
+
+        foreach (var file in old_uris) {
+            // Make sure we're not trying to harvest the removed URI.
+            this.harvester.cancel (file);
+            try {
+                this.media_db.remove_by_id (MediaCache.get_id (file.get_uri ()));
+            } catch (DatabaseError error) {
+                warning (_("Failed to remove entry: %s"), error.message);
+            }
+        }
+
+        this.harvester.locations.remove_all (old_uris);
+
+        if (!new_uris.is_empty) {
+            this.filesystem_container.disconnect (this.filesystem_signal_id);
+            this.filesystem_signal_id = 0;
+            this.harvester_signal_id = this.harvester.done.connect
+                                            (on_initial_harvesting_done);
+        }
+
+        foreach (var file in new_uris) {
+            if (file.query_exists ()) {
+                this.harvester.locations.add (file);
+                this.harvester.schedule (file, this.filesystem_container);
+            }
+        }
+    }
+
+    private void handle_virtual_folder_change () {
+        var virtual_folders = true;
+        var config = MetaConfig.get_default ();
+        try {
+            virtual_folders = config.get_bool (Plugin.NAME, "virtual-folders");
+        } catch (Error error) { }
+
+        if (virtual_folders) {
+            this.add_default_virtual_folders ();
+
+            return;
+        } else {
+            this.media_db.drop_virtual_folders ();
+        }
+        this.root_updated ();
+    }
+
+    private void on_initial_harvesting_done () {
+        // Disconnect the signal handler.
+        this.harvester.disconnect (this.harvester_signal_id);
+        this.harvester_signal_id = 0;
+
+        // Some debug output:
+        this.media_db.debug_statistics ();
+
+        // Now that the filesystem scanning is done,
+        // also add the virtual folders:
+        this.add_default_virtual_folders ();
+
+        // Signal that the container has changed:
+        this.root_updated ();
+
+        // When the filesystem container changes,
+        // re-add the virtual folders, to update them.
+        this.filesystem_signal_id =
+            this.filesystem_container.container_updated.connect ( () => {
+                this.add_default_virtual_folders ();
+                this.root_updated ();
+            });
+
+    }
+
+    /** Add the default virtual folders,
+     * for Music, Pictures, etc,
+     * saving them in the cache.
+     */
+    private void add_default_virtual_folders () {
+        var virtual_folders = true;
+        var config = MetaConfig.get_default ();
+        try {
+            virtual_folders = config.get_bool (Plugin.NAME, "virtual-folders");
+        } catch (Error error) { }
+
+        if (!virtual_folders) {
+            return;
+        }
+
+        try {
+            this.add_virtual_containers_for_class (_("Videos"),
+                                                   Rygel.VideoItem.UPNP_CLASS);
+        } catch (Error error) {};
+    }
+
+    /**
+     * Add a QueryContainer to the provided container,
+     * for the specified UPnP class,
+     * with the specified definition,
+     * saving it in the cache.
+     */
+    private void add_folder_definition (MediaContainer   container,
+                                        string           item_class,
+                                        FolderDefinition definition)
+                                        throws Error {
+
+        // Create a container ID that contains the virtual folder definition.
+        var id = "%supnp:class,%s,%s".printf (QueryContainer.PREFIX,
+                                               item_class,
+                                               definition.definition);
+        if (id.has_suffix (",")) {
+            id = id.slice (0,-1);
+        }
+
+        // Create a QueryContainer based on the definition in the ID.
+        var factory = QueryContainerFactory.get_default ();
+        var query_container = factory.create_from_description_id
+                                        (id,
+                                         _(definition.title));
+
+        // The QueryContainer's constructor has already run some
+        // SQL to count the number of items.
+        // We remove the container if it has no children.
+        //
+        // Note that all the virtual folders are re-added anyway
+        // when the filesystem changes, so there is no need for
+        // the container to exist in case the query would have
+        // a non-zero result later.
+        if (query_container.child_count > 0) {
+            query_container.parent = container;
+            this.media_db.save_container (query_container);
+        } else {
+            this.media_db.remove_by_id (id);
+        }
+    }
+
+    /**
+     * Add a parent container with child containers for the definitions,
+     * saving them in the cache.
+     */
+    private void add_virtual_containers_for_class
+                                        (string              parent,
+                                         string              item_class,
+                                         FolderDefinition[]? definitions = null)
+                                         throws Error {
+        // Create a container for this virtual folder.
+        // We use a NullContainer just because our MediaCache API takes 
+        // objects and, after saving the details in the database,
+        // it discards the actual container object anyway.
+        // 
+        // This ID prefix is checked later in ObjectFactory.get_container(),
+        // which returns a regular DBContainer instead.
+        var container = new NullContainer ("virtual-parent:" + item_class, this, parent);
+        this.media_db.save_container (container);
+
+        // Add a child QueryContainer for each of the default definitions.
+        foreach (var definition in VIRTUAL_FOLDERS_DEFAULT) {
+            this.add_folder_definition (container, item_class, definition);
+        }
+
+        // Add a child QueryContainer for each of the additional specified definitions.
+        if (definitions != null) {
+            foreach (var definition in definitions) {
+                this.add_folder_definition (container, item_class, definition);
+            }
+        }
+
+        // If no child QueryContainers were added, remove
+        // the provided parent container. Unless it's the Playlist container.
+        if (this.media_db.get_child_count (container.id) == 0 &&
+            !container.id.has_prefix ("virtual-parent:" +
+                                      Rygel.PlaylistItem.UPNP_CLASS)) {
+            this.media_db.remove_by_id (container.id);
+        } else {
+            container.updated ();
+        }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-sql-factory.vala b/src/plugins/odid/rygel-odid-sql-factory.vala
new file mode 100644
index 0000000..afb5371
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-sql-factory.vala
@@ -0,0 +1,394 @@
+/*
+ * Copyright (C) 2010,2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+internal enum Rygel.ODID.ResourceColumn {
+    SIZE,
+    PROTOCOL_INFO,
+    CLEARTEXT_SIZE,
+    DURATION,
+    WIDTH,
+    HEIGHT,
+    ALBUM,
+    GENRE,
+    BITRATE,
+    SAMPLE_FREQ,
+    BITS_PER_SAMPLE,
+    CHANNELS,
+    TRACK,
+    DISK,
+    EXTERNAL_URI,
+    NAME,
+    EXTENSION,
+    COLOR_DEPTH
+}
+
+internal enum Rygel.ODID.ObjectColumn {
+    TYPE,
+    TITLE,
+    ID,
+    PARENT,
+    CLASS,
+    DATE,
+    CREATOR,
+    TIMESTAMP,
+    URI,
+    OBJECT_UPDATE_ID,
+    DELETED_CHILD_COUNT,
+    CONTAINER_UPDATE_ID,
+    REFERENCE_ID
+}
+
+internal enum Rygel.ODID.SQLString {
+    SAVE_RESOURCE,
+    DELETE_RESOURCES,
+    INSERT,
+    DELETE,
+    GET_OBJECT,
+    GET_CHILDREN,
+    GET_OBJECTS_BY_FILTER,
+    GET_OBJECTS_BY_FILTER_WITH_ANCESTOR,
+    GET_OBJECT_COUNT_BY_FILTER,
+    GET_OBJECT_COUNT_BY_FILTER_WITH_ANCESTOR,
+    GET_RESOURCES_BY_OBJECT,
+    GET_RESOURCE_COLUMN,
+    CHILD_COUNT,
+    EXISTS,
+    CHILD_IDS,
+    TABLE_RESOURCE,
+    TABLE_CLOSURE,
+    TRIGGER_CLOSURE,
+    TRIGGER_COMMON,
+    INDEX_COMMON,
+    SCHEMA,
+    EXISTS_CACHE,
+    STATISTICS,
+    RESET_TOKEN,
+    MAX_UPDATE_ID,
+    MAKE_GUARDED,
+    IS_GUARDED,
+    UPDATE_GUARDED_OBJECT,
+    TRIGGER_REFERENCE
+}
+
+internal class Rygel.ODID.SQLFactory : Object {
+    private const string SAVE_RESOURCE_STRING =
+    "INSERT OR REPLACE INTO Resource " +
+        "(size, width, height, " +
+         "author, album, bitrate, " +
+         "sample_freq, bits_per_sample, channels, " +
+         "track, color_depth, duration, object_fk, " +
+         "protocol_info, cleartext_size, genre, disc, external_uri, name, extension, residx) VALUES " +
+         "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
+
+    private const string INSERT_OBJECT_STRING =
+    "INSERT OR REPLACE INTO Object " +
+        "(upnp_id, title, type_fk, parent, class, date, creator, timestamp, uri, " +
+         "object_update_id, deleted_child_count, container_update_id, " +
+         "is_guarded, reference_id) VALUES " +
+        "(?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
+
+    private const string UPDATE_GUARDED_OBJECT_STRING =
+    "UPDATE Object SET " +
+        "type_fk = ?, " +
+        "parent = ?, " +
+        "class = ?, " +
+        "date = ?, " +
+        "creator = ?, " +
+        "timestamp = ?, " +
+        "uri = ?, " +
+        "object_update_id = ?, " +
+        "deleted_child_count = ?, " +
+        "container_update_id = ? " +
+        "where upnp_id = ?";
+
+    private const string DELETE_BY_ID_STRING =
+    "DELETE FROM Object WHERE upnp_id IN " +
+        "(SELECT descendant FROM closure WHERE ancestor = ?)";
+
+    private const string DELETE_RESOURCES_BY_ID_STRING =
+    "DELETE from Resource WHERE object_fk = ?";   
+
+    private const string ALL_OBJECT_STRING =
+    "o.type_fk, o.title, o.upnp_id, o.parent, o.class, o.date, o.creator, o.timestamp, " +
+    "o.uri, o.object_update_id, " +
+    "o.deleted_child_count, o.container_update_id, o.reference_id ";
+
+    private const string ALL_RESOURCE_STRING =
+    "r.size, r.protocol_info, r.cleartext_size, r.duration, r.width, r.height, r.album, r.genre, r.bitrate, r.sample_freq, r.bits_per_sample, r.channels, r.track, r.disc, r.external_uri, r.name, r.extension, r.color_depth ";
+
+    private const string GET_OBJECT_WITH_PATH =
+    "SELECT DISTINCT " + ALL_OBJECT_STRING +
+    "FROM Object o " +
+        "JOIN Closure c ON (o.upnp_id = c.ancestor) " +
+            "WHERE c.descendant = ? ORDER BY c.depth DESC";
+
+    /**
+     * This is the database query used to retrieve the children for a
+     * given object.
+     *
+     * Sorting is as follows:
+     *   - by type: containers first, then items if both are present
+     *   - by upnp_class: items are sorted according to their class
+     *   - by track: sorted by track
+     *   - and after that alphabetically
+     */
+    private const string GET_CHILDREN_STRING =
+    "SELECT " + ALL_OBJECT_STRING +
+    "FROM Object o " +
+        "JOIN Closure c ON (o.upnp_id = c.descendant) " +
+    "LEFT OUTER JOIN Resource r " +
+    "ON o.upnp_id = r.object_fk "+
+    "WHERE c.ancestor = ? AND c.depth = 1 %s " +
+    "LIMIT ?,?";
+
+    private const string GET_OBJECTS_BY_FILTER_STRING_WITH_ANCESTOR =
+    "SELECT DISTINCT " + ALL_OBJECT_STRING +
+    "FROM Object o " +
+        "JOIN Closure c ON o.upnp_id = c.descendant AND c.ancestor = ? " +
+        "LEFT OUTER JOIN Resource r " +
+            "ON o.upnp_id = r.object_fk %s %s " +
+    "LIMIT ?,?";
+
+    private const string GET_OBJECTS_BY_FILTER_STRING =
+    "SELECT DISTINCT " + ALL_OBJECT_STRING +
+    "FROM Object o " +
+        "LEFT OUTER JOIN Resource r " +
+            "ON o.upnp_id = r.object_fk %s %s " +
+    "LIMIT ?,?";
+
+    private const string GET_OBJECT_COUNT_BY_FILTER_STRING_WITH_ANCESTOR =
+    "SELECT DISTINCT COUNT(o.type_fk) FROM Object o " +
+        "JOIN Closure c ON o.upnp_id = c.descendant AND c.ancestor = ? " +
+        "LEFT OUTER JOIN Resource r " +
+            "ON o.upnp_id = r.object_fk %s";
+
+    private const string GET_OBJECT_COUNT_BY_FILTER_STRING =
+    "SELECT COUNT(1) FROM Resource r %s";
+
+    private const string GET_RESOURCES_BY_OBJECT_STRING = 
+    "SELECT " + ALL_RESOURCE_STRING + " FROM Resource r " +
+        "WHERE r.object_fk = ? ORDER BY r.residx";
+
+    private const string CHILDREN_COUNT_STRING =
+    "SELECT COUNT(upnp_id) FROM Object WHERE Object.parent = ?";
+
+    private const string OBJECT_EXISTS_STRING =
+    "SELECT COUNT(1), timestamp, r.size FROM Object " +
+        "JOIN Resource r ON r.object_fk = upnp_id " +
+        "WHERE Object.uri = ?";
+
+    private const string GET_CHILD_ID_STRING =
+    "SELECT upnp_id FROM OBJECT WHERE parent = ?";
+
+    private const string GET_RESOURCE_COLUMN_STRING =
+    "SELECT DISTINCT %s AS _column FROM Resource AS r " +
+        "WHERE _column IS NOT NULL %s ORDER BY _column COLLATE CASEFOLD " +
+    "LIMIT ?,?";
+
+    internal const string SCHEMA_VERSION = "3";
+    internal const string CREATE_RESOURCE_TABLE_STRING =
+    "CREATE TABLE resource (size INTEGER, " +
+                            "protocol_info TEXT, " +
+                            "cleartext_size INTEGER, " +
+                            "duration INTEGER, " +
+                            "width INTEGER, " +
+                            "height INTEGER, " +
+                            "author TEXT, " +
+                            "album TEXT, " +
+                            "genre TEXT, " +
+                            "bitrate INTEGER, " +
+                            "sample_freq INTEGER, " +
+                            "bits_per_sample INTEGER, " +
+                            "channels INTEGER, " +
+                            "track INTEGER, " +
+                            "disc INTEGER, " +
+                            "external_uri TEXT, " +
+                            "name TEXT NOT NULL, " +
+                            "extension TEXT, " +
+                            "color_depth INTEGER, " +
+                            "residx INTEGER NOT NULL, " +
+                            "object_fk TEXT CONSTRAINT " +
+                                "object_fk_id REFERENCES Object(upnp_id) " +
+                                    "ON DELETE CASCADE,  " +
+                             "PRIMARY KEY (name, object_fk));";
+
+    private const string SCHEMA_STRING =
+    "CREATE TABLE schema_info (version TEXT NOT NULL, " +
+                              "reset_token TEXT); " +
+    CREATE_RESOURCE_TABLE_STRING +
+    "CREATE TABLE object (parent TEXT CONSTRAINT parent_fk_id " +
+                                "REFERENCES Object(upnp_id), " +
+                          "upnp_id TEXT PRIMARY KEY, " +
+                          "type_fk INTEGER, " +
+                          "title TEXT NOT NULL, " +
+                          "class TEXT NOT NULL, " +
+                          "date TEXT, " +
+                          "creator TEXT, " +
+                          "timestamp INTEGER NOT NULL, " +
+                          "uri TEXT, " +
+                          "object_update_id INTEGER, " +
+                          "deleted_child_count INTEGER, " +
+                          "container_update_id INTEGER, " +
+                          "is_guarded INTEGER, " +
+                          "reference_id TEXT DEFAULT NULL);" +
+    "INSERT INTO schema_info (version) VALUES ('" +
+    SQLFactory.SCHEMA_VERSION + "'); ";
+
+    private const string CREATE_CLOSURE_TABLE =
+    "CREATE TABLE closure (ancestor TEXT, descendant TEXT, depth INTEGER)";
+
+    private const string CREATE_CLOSURE_TRIGGER_STRING =
+    "CREATE TRIGGER trgr_update_closure " +
+    "AFTER INSERT ON Object " +
+    "FOR EACH ROW BEGIN " +
+        "SELECT RAISE(IGNORE) WHERE (SELECT COUNT(*) FROM Closure " +
+            "WHERE ancestor = NEW.upnp_id " +
+                  "AND descendant = NEW.upnp_id " +
+                  "AND depth = 0) != 0;" +
+        "INSERT INTO Closure (ancestor, descendant, depth) " +
+            "VALUES (NEW.upnp_id, NEW.upnp_id, 0); " +
+        "INSERT INTO Closure (ancestor, descendant, depth) " +
+            "SELECT ancestor, NEW.upnp_id, depth + 1 FROM Closure " +
+                "WHERE descendant = NEW.parent;" +
+    "END;" +
+
+    "CREATE TRIGGER trgr_delete_closure " +
+    "AFTER DELETE ON Object " +
+    "FOR EACH ROW BEGIN " +
+        "DELETE FROM Closure WHERE descendant = OLD.upnp_id;" +
+    "END;";
+
+    // these triggers emulate ON DELETE CASCADE
+    private const string CREATE_TRIGGER_STRING =
+    "CREATE TRIGGER trgr_delete_resource " +
+    "BEFORE DELETE ON Object " +
+    "FOR EACH ROW BEGIN " +
+        "DELETE FROM Resource WHERE Resource.object_fk = OLD.upnp_id; "+
+    "END;";
+
+    private const string DELETE_REFERENCE_TRIGGER_STRING =
+    "CREATE TRIGGER trgr_delete_references " +
+    "BEFORE DELETE ON Object " +
+    "FOR EACH ROW BEGIN " +
+        "DELETE FROM Object WHERE OLD.upnp_id = Object.reference_id; " +
+    "END;";
+
+    private const string CREATE_INDICES_STRING =
+    "CREATE INDEX IF NOT EXISTS idx_parent on Object(parent);" +
+    "CREATE INDEX IF NOT EXISTS idx_object_upnp_id on Object(upnp_id);" +
+    "CREATE INDEX IF NOT EXISTS idx_resource_fk on Resource(object_fk);" +
+    "CREATE INDEX IF NOT EXISTS idx_closure on Closure(descendant,depth);" +
+    "CREATE INDEX IF NOT EXISTS idx_closure_descendant on Closure(descendant);" +
+    "CREATE INDEX IF NOT EXISTS idx_closure_ancestor on Closure(ancestor);" +
+    "CREATE INDEX IF NOT EXISTS idx_uri on Object(uri);" +
+    "CREATE INDEX IF NOT EXISTS idx_object_date on Object(date);" +
+    "CREATE INDEX IF NOT EXISTS idx_resource_genre on Resource(genre);" +
+    "CREATE INDEX IF NOT EXISTS idx_resource_album on Resource(album);" +
+    "CREATE INDEX IF NOT EXISTS idx_resource_artist_album on " +
+                                "Resource(author, album);";
+
+    private const string EXISTS_CACHE_STRING =
+    "SELECT DISTINCT r.size, o.timestamp, o.uri FROM Object o " +
+        "JOIN Resource r ON o.upnp_id = r.object_fk";
+
+    private const string STATISTICS_STRING =
+    "SELECT class, count(1) FROM object GROUP BY class";
+
+    private const string RESET_TOKEN_STRING =
+    "SELECT reset_token FROM schema_info";
+
+    private const string MAX_UPDATE_ID_STRING =
+    "SELECT MAX(MAX(object_update_id), MAX(container_update_id)) FROM Object";
+
+    private const string MAKE_GUARDED_STRING =
+    "UPDATE Object SET is_guarded = ? WHERE Object.upnp_id = ?";
+
+    private const string IS_GUARDED_STRING =
+    "SELECT is_guarded FROM Object WHERE Object.upnp_id = ?";
+
+    public unowned string make (SQLString query) {
+        switch (query) {
+            case SQLString.SAVE_RESOURCE:
+                return SAVE_RESOURCE_STRING;
+            case SQLString.DELETE_RESOURCES:
+                return DELETE_RESOURCES_BY_ID_STRING;
+            case SQLString.INSERT:
+                return INSERT_OBJECT_STRING;
+            case SQLString.DELETE:
+                return DELETE_BY_ID_STRING;
+            case SQLString.GET_OBJECT:
+                return GET_OBJECT_WITH_PATH;
+            case SQLString.GET_CHILDREN:
+                return GET_CHILDREN_STRING;
+            case SQLString.GET_OBJECTS_BY_FILTER:
+                return GET_OBJECTS_BY_FILTER_STRING;
+            case SQLString.GET_OBJECTS_BY_FILTER_WITH_ANCESTOR:
+                return GET_OBJECTS_BY_FILTER_STRING_WITH_ANCESTOR;
+            case SQLString.GET_OBJECT_COUNT_BY_FILTER:
+                return GET_OBJECT_COUNT_BY_FILTER_STRING;
+            case SQLString.GET_OBJECT_COUNT_BY_FILTER_WITH_ANCESTOR:
+                return GET_OBJECT_COUNT_BY_FILTER_STRING_WITH_ANCESTOR;
+            case SQLString.GET_RESOURCES_BY_OBJECT:
+                return GET_RESOURCES_BY_OBJECT_STRING;
+            case SQLString.GET_RESOURCE_COLUMN:
+                return GET_RESOURCE_COLUMN_STRING;
+            case SQLString.CHILD_COUNT:
+                return CHILDREN_COUNT_STRING;
+            case SQLString.EXISTS:
+                return OBJECT_EXISTS_STRING;
+            case SQLString.CHILD_IDS:
+                return GET_CHILD_ID_STRING;
+            case SQLString.TABLE_RESOURCE:
+                return CREATE_RESOURCE_TABLE_STRING;
+            case SQLString.TRIGGER_COMMON:
+                return CREATE_TRIGGER_STRING;
+            case SQLString.TRIGGER_CLOSURE:
+                return CREATE_CLOSURE_TRIGGER_STRING;
+            case SQLString.INDEX_COMMON:
+                return CREATE_INDICES_STRING;
+            case SQLString.SCHEMA:
+                return SCHEMA_STRING;
+            case SQLString.EXISTS_CACHE:
+                return EXISTS_CACHE_STRING;
+            case SQLString.TABLE_CLOSURE:
+                return CREATE_CLOSURE_TABLE;
+            case SQLString.STATISTICS:
+                return STATISTICS_STRING;
+            case SQLString.RESET_TOKEN:
+                return RESET_TOKEN_STRING;
+            case SQLString.MAX_UPDATE_ID:
+                return MAX_UPDATE_ID_STRING;
+            case SQLString.MAKE_GUARDED:
+                return MAKE_GUARDED_STRING;
+            case SQLString.IS_GUARDED:
+                return IS_GUARDED_STRING;
+            case SQLString.UPDATE_GUARDED_OBJECT:
+                return UPDATE_GUARDED_OBJECT_STRING;
+            case SQLString.TRIGGER_REFERENCE:
+                return DELETE_REFERENCE_TRIGGER_STRING;
+            default:
+                assert_not_reached ();
+        }
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-sql-function.vala b/src/plugins/odid/rygel-odid-sql-function.vala
new file mode 100644
index 0000000..c1124fa
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-sql-function.vala
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+internal class Rygel.ODID.SqlFunction : SqlOperator {
+    public SqlFunction (string name, string arg) {
+        base (name, arg);
+    }
+
+    public override string to_string () {
+        return "%s(%s,?)".printf (name, arg);
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-sql-operator.vala b/src/plugins/odid/rygel-odid-sql-operator.vala
new file mode 100644
index 0000000..659ef08
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-sql-operator.vala
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using GUPnP;
+
+internal class Rygel.ODID.SqlOperator : GLib.Object {
+    protected string name;
+    protected string arg;
+    protected string collate;
+
+    public SqlOperator (string name,
+                        string arg,
+                        string collate = "") {
+        this.name = name;
+        this.arg = arg;
+        this.collate = collate;
+    }
+
+    public SqlOperator.from_search_criteria_op (SearchCriteriaOp op,
+                                                string           arg,
+                                                string           collate) {
+        string sql = null;
+        switch (op) {
+            case SearchCriteriaOp.EQ:
+                sql = "=";
+                break;
+            case SearchCriteriaOp.NEQ:
+                sql = "!=";
+                break;
+            case SearchCriteriaOp.LESS:
+                sql = "<";
+                break;
+            case SearchCriteriaOp.LEQ:
+                sql = "<=";
+                break;
+            case SearchCriteriaOp.GREATER:
+                sql = ">";
+                break;
+            case SearchCriteriaOp.GEQ:
+                sql = ">=";
+                break;
+            default:
+                assert_not_reached ();
+        }
+
+        this (sql, arg, collate);
+    }
+
+    public virtual string to_string () {
+        return "(%s %s ? %s)".printf (arg, name, collate);
+    }
+}
+
+
diff --git a/src/plugins/odid/rygel-odid-sqlite-wrapper.vala b/src/plugins/odid/rygel-odid-sqlite-wrapper.vala
new file mode 100644
index 0000000..42899c6
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-sqlite-wrapper.vala
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2011 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Sqlite;
+
+internal class Rygel.ODID.SqliteWrapper : Object {
+    private Sqlite.Database database = null;
+    private Sqlite.Database *reference = null;
+
+    /**
+     * Property to access the wrapped database
+     */
+    protected unowned Sqlite.Database db {
+        get { return reference; }
+    }
+
+    /**
+     * Wrap an existing SQLite Database object.
+     *
+     * The SqliteWrapper doesn't take ownership of the passed db
+     */
+    public SqliteWrapper.wrap (Sqlite.Database db) {
+        this.reference = db;
+    }
+
+    /**
+     * Create or open a new SQLite database in path.
+     *
+     * @note: Path may also be ":memory:" for temporary databases
+     */
+    public SqliteWrapper (string path) throws DatabaseError {
+        Sqlite.Database.open (path, out this.database);
+        this.reference = this.database;
+        this.throw_if_db_has_error ();
+    }
+
+    /**
+     * Convert a SQLite return code to a DatabaseError
+     */
+    protected void throw_if_code_is_error (int sqlite_error)
+                                           throws DatabaseError {
+        switch (sqlite_error) {
+            case Sqlite.OK:
+            case Sqlite.DONE:
+            case Sqlite.ROW:
+                return;
+            default:
+                throw new DatabaseError.SQLITE_ERROR
+                                        ("SQLite error %d: %s",
+                                         sqlite_error,
+                                         this.reference->errmsg ());
+        }
+    }
+
+    /**
+     * Check if the last operation on the database was an error
+     */
+    protected void throw_if_db_has_error () throws DatabaseError {
+        this.throw_if_code_is_error (this.reference->errcode ());
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-trackable-db-container.vala b/src/plugins/odid/rygel-odid-trackable-db-container.vala
new file mode 100644
index 0000000..b9f3f31
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-trackable-db-container.vala
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2012 Intel Corporation.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+/**
+ * A DB container that is trackable.
+ */
+public class Rygel.ODID.TrackableDbContainer : DBContainer,
+                                                      TrackableContainer {
+    public TrackableDbContainer (string id, string title) {
+        Object (id : id,
+                title : title,
+                parent : null,
+                child_count : 0);
+    }
+
+    public override void constructed () {
+        base.constructed ();
+
+        this.child_added.connect (on_child_added);
+        this.child_removed.connect (on_child_removed);
+    }
+
+    private void on_child_added (MediaObject object) {
+        try {
+            var cache = this.media_db;
+
+            if (object is MediaItem) {
+                cache.save_item (object as MediaItem);
+            } else if (object is MediaContainer) {
+                 cache.save_container (object as MediaContainer);
+            } else {
+                assert_not_reached ();
+            }
+            cache.save_container (this);
+        } catch (Error error) {
+            warning ("Failed to save object: %s", error.message);
+        }
+    }
+
+    private void on_child_removed (MediaObject object) {
+        try {
+            this.media_db.save_container (this);
+        } catch (Error error) {
+            warning ("Failed to save object: %s", error.message);
+        }
+    }
+
+    // TrackableContainer virtual function implementations:
+    protected async void add_child (MediaObject object) {
+        try {
+            if (object is MediaItem) {
+                this.media_db.save_item (object as MediaItem);
+            } else if (object is MediaContainer) {
+                this.media_db.save_container (object as MediaContainer);
+            } else {
+                assert_not_reached ();
+            }
+        } catch (Error error) {
+            warning ("Failed to add object: %s", error.message);
+        }
+    }
+
+    protected async void remove_child (MediaObject object) {
+        try {
+            this.media_db.remove_object (object);
+        } catch (Error error) {
+            warning ("Failed to remove object: %s", error.message);
+        }
+    }
+
+    // TrackableContainer overrides
+    public virtual string get_service_reset_token () {
+        return this.media_db.get_reset_token ();
+    }
+
+    public virtual void set_service_reset_token (string token) {
+        this.media_db.save_reset_token (token);
+    }
+
+    public virtual uint32 get_system_update_id () {
+        // Get the last-known System Update ID,
+        // from any previous run of this service,
+        // based on the max ID found in the cache database.
+        var id = this.media_db.get_update_id ();
+        return id;
+    }
+}
diff --git a/src/plugins/odid/rygel-odid-updatable-object.vala b/src/plugins/odid/rygel-odid-updatable-object.vala
new file mode 100644
index 0000000..2bde555
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-updatable-object.vala
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2013 Intel Corporation.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Krzesimir Nowak <krnowak@openismus.com>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+public interface Rygel.ODID.UpdatableObject : Rygel.MediaObject {
+    public async void non_overriding_commit () throws Error {
+        yield this.commit_custom (false);
+    }
+
+    public abstract async void commit_custom (bool override_guarded)
+                                              throws Error;
+}
diff --git a/src/plugins/odid/rygel-odid-writable-db-container.vala b/src/plugins/odid/rygel-odid-writable-db-container.vala
new file mode 100644
index 0000000..be1d217
--- /dev/null
+++ b/src/plugins/odid/rygel-odid-writable-db-container.vala
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2010 Jens Georg <mail@jensge.org>.
+ * Copyright (C) 2013 Cable Television Laboratories, Inc.
+ *
+ * Author: Jens Georg <mail@jensge.org>
+ *         Doug Galligan <doug@sentosatech.com>
+ *
+ * This file is part of Rygel.
+ *
+ * Rygel 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 of the License, or
+ * (at your option) any later version.
+ *
+ * Rygel 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.
+ */
+
+using Gee;
+
+/**
+ * A DB container that is both Trackable and Writable.
+ *
+ * Clients can upload items to this container, causing
+ * the items to be saved to the filesystem to be
+ * served again subsequently.
+ */
+internal class Rygel.ODID.WritableDbContainer : TrackableDbContainer,
+                                                Rygel.WritableContainer {
+    public ArrayList<string> create_classes { get; set; }
+
+    public WritableDbContainer (string id, string title) {
+        Object (id : id,
+                title : title,
+                parent : null,
+                child_count : 0);
+    }
+
+    public override void constructed () {
+        base.constructed ();
+
+        this.create_classes = new ArrayList<string> ();
+        bool allow_upload = false;
+        var upload_options = new Gee.ArrayList<string>();
+        try {
+            var config = MetaConfig.get_default ();
+            allow_upload = config.get_allow_upload ();
+            upload_options = config.get_string_list
+                                    ("general", "upload-option");
+        } catch (GLib.Error error) { }
+
+        if (allow_upload) {
+             // Items
+            foreach (var option in upload_options) {
+                if (option == "image-upload") {
+                    this.create_classes.add (Rygel.ImageItem.UPNP_CLASS);
+                    this.create_classes.add (Rygel.PhotoItem.UPNP_CLASS);
+                } else if (option == "av-upload") {
+                    this.create_classes.add (Rygel.VideoItem.UPNP_CLASS);
+                } else if (option == "audio-upload") {
+                    this.create_classes.add (Rygel.AudioItem.UPNP_CLASS);
+                    this.create_classes.add (Rygel.MusicItem.UPNP_CLASS);
+                    this.create_classes.add (Rygel.PlaylistItem.UPNP_CLASS);
+                }
+            }
+
+            // Containers
+            this.create_classes.add (Rygel.MediaContainer.UPNP_CLASS);
+        }
+    }
+
+    public virtual async void add_item (Rygel.MediaFileItem item,
+                                        Cancellable? cancellable)
+                                        throws Error {
+        item.parent = this;
+        var file = File.new_for_uri (item.uris[0]);
+        // TODO: Mark as place-holder. Make this proper some time.
+        if (file.is_native ()) {
+            item.modified = int64.MAX;
+        }
+        item.id = MediaCache.get_id (file.get_uri ());
+        yield this.add_child_tracked (item);
+        this.media_db.make_object_guarded (item);
+    }
+
+    public virtual async string add_reference (MediaObject  object,
+                                               Cancellable? cancellable)
+                                               throws Error {
+        return MediaCache.get_default ().create_reference (object, this);
+    }
+
+    public virtual async void add_container (MediaContainer container,
+                                             Cancellable?   cancellable)
+                                             throws Error {
+        container.parent = this;
+        switch (container.upnp_class) {
+        case MediaContainer.STORAGE_FOLDER:
+        case MediaContainer.UPNP_CLASS:
+            var file = File.new_for_uri (container.uris[0]);
+            container.id = MediaCache.get_id (file.get_uri ());
+            if (file.is_native ()) {
+                file.make_directory_with_parents (cancellable);
+            }
+            break;
+        default:
+            throw new WritableContainerError.NOT_IMPLEMENTED
+                                        ("upnp:class %s not supported",
+                                         container.upnp_class);
+        }
+
+        yield this.add_child_tracked (container);
+        this.media_db.make_object_guarded (container);
+    }
+
+    public virtual async void remove_item (string id, Cancellable? cancellable)
+                                           throws Error {
+        var object = this.media_db.get_object (id);
+
+        yield this.remove_child_tracked (object);
+    }
+
+    public virtual async void remove_container (string id,
+                                                Cancellable? cancellable)
+                                                throws Error {
+        var container = this.media_db.get_object (id);
+
+        yield this.remove_child_tracked (container);
+    }
+
+}
