Merge playlist branch against trunk to date.
authorRichard Kettlewell <rjk@greenend.org.uk>
Tue, 17 Feb 2009 20:29:50 +0000 (20:29 +0000)
committerRichard Kettlewell <rjk@greenend.org.uk>
Tue, 17 Feb 2009 20:29:50 +0000 (20:29 +0000)
26 files changed:
1  2 
clients/disorder.c
disobedience/Makefile.am
disobedience/disobedience.c
disobedience/disobedience.h
disobedience/log.c
disobedience/menu.c
disobedience/queue-generic.c
disobedience/queue-generic.h
disobedience/queue.c
doc/disorder.1.in
doc/disorder_protocol.5.in
lib/Makefile.am
lib/client.c
lib/client.h
lib/configuration.c
lib/configuration.h
lib/eclient.c
lib/eclient.h
lib/trackdb-int.h
lib/trackdb.c
lib/trackdb.h
python/disorder.py.in
scripts/completion.bash
server/dump.c
server/server.c
tests/Makefile.am

diff --combined clients/disorder.c
@@@ -2,21 -2,20 +2,21 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2004-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +/** @file clients/disorder.c
 + * @brief Command-line client
   */
  
  #include "common.h"
@@@ -32,6 -31,7 +32,7 @@@
  #include <unistd.h>
  #include <pcre.h>
  #include <ctype.h>
+ #include <langinfo.h>
  
  #include "configuration.h"
  #include "syscalls.h"
@@@ -50,6 -50,7 +51,7 @@@
  #include "vector.h"
  #include "version.h"
  #include "dateparse.h"
+ #include "inputline.h"
  
  static disorder_client *client;
  
@@@ -99,17 -100,8 +101,17 @@@ static void cf_version(char attribute((
  static void print_queue_entry(const struct queue_entry *q) {
    if(q->track) xprintf("track %s\n", nullcheck(utf82mb(q->track)));
    if(q->id) xprintf("  id %s\n", nullcheck(utf82mb(q->id)));
 -  if(q->submitter) xprintf("  submitted by %s at %s",
 -                         nullcheck(utf82mb(q->submitter)), ctime(&q->when));
 +  switch(q->origin) {
 +  case origin_adopted:
 +  case origin_picked:
 +  case origin_scheduled:
 +    xprintf("  %s by %s at %s",
 +            track_origins[q->origin],
 +            nullcheck(utf82mb(q->submitter)), ctime(&q->when));
 +    break;
 +  default:
 +    break;
 +  }
    if(q->played) xprintf("  played at %s", ctime(&q->played));
    if(q->state == playing_started
       || q->state == playing_paused) xprintf("  %lds so far",  q->sofar);
@@@ -185,15 -177,34 +187,34 @@@ static void cf_queue(char attribute((un
  }
  
  static void cf_quack(char attribute((unused)) **argv) {
-   xprintf("\n"
-         " .------------------.\n"
-         " | Naath is a babe! |\n"
-         " `---------+--------'\n"
-         "            \\\n"
-         "              >0\n"
-         "               (<)'\n"
-         "~~~~~~~~~~~~~~~~~~~~~~\n"
-         "\n");
+   if(!strcasecmp(nl_langinfo(CODESET), "utf-8")) {
+ #define TL "\xE2\x95\xAD"
+ #define TR "\xE2\x95\xAE"
+ #define BR "\xE2\x95\xAF"
+ #define BL "\xE2\x95\xB0"
+ #define H "\xE2\x94\x80"
+ #define V "\xE2\x94\x82"
+ #define T "\xE2\x94\xAC"
+     xprintf("\n"
+             " "TL H H H H H H H H H H H H H H H H H H TR"\n"
+             " "V" Naath is a babe! "V"\n"
+             " "BL H H H H H H H H H T H H H H H H H H BR"\n"
+             "            \\\n"
+             "              >0\n"
+             "               (<)'\n"
+             "~~~~~~~~~~~~~~~~~~~~~~\n"
+             "\n");
+   } else {
+     xprintf("\n"
+             " .------------------.\n"
+             " | Naath is a babe! |\n"
+             " `---------+--------'\n"
+             "            \\\n"
+             "              >0\n"
+             "               (<)'\n"
+             "~~~~~~~~~~~~~~~~~~~~~~\n"
+             "\n");
+   }
  }
  
  static void cf_somelist(char **argv,
@@@ -577,11 -588,61 +598,66 @@@ static void cf_schedule_unset_global(ch
      exit(EXIT_FAILURE);
  }
  
 +static void cf_adopt(char **argv) {
 +  if(disorder_adopt(getclient(), argv[0]))
 +    exit(EXIT_FAILURE);
 +}
 +
+ static void cf_playlists(char attribute((unused)) **argv) {
+   char **vec;
+   if(disorder_playlists(getclient(), &vec, 0))
+     exit(EXIT_FAILURE);
+   while(*vec)
+     xprintf("%s\n", nullcheck(utf82mb(*vec++)));
+ }
+ static void cf_playlist_del(char **argv) {
+   if(disorder_playlist_delete(getclient(), argv[0]))
+     exit(EXIT_FAILURE);
+ }
+ static void cf_playlist_get(char **argv) {
+   char **vec;
+   if(disorder_playlist_get(getclient(), argv[0], &vec, 0))
+     exit(EXIT_FAILURE);
+   while(*vec)
+     xprintf("%s\n", nullcheck(utf82mb(*vec++)));
+ }
+ static void cf_playlist_set(char **argv) {
+   struct vector v[1];
+   FILE *input;
+   const char *tag;
+   char *l;
+   if(argv[1]) {
+     // Read track list from file
+     if(!(input = fopen(argv[1], "r")))
+       fatal(errno, "opening %s", argv[1]);
+     tag = argv[1];
+   } else {
+     // Read track list from standard input
+     input = stdin;
+     tag = "stdin";
+   }
+   vector_init(v);
+   while(!inputline(tag, input, &l, '\n')) {
+     if(!strcmp(l, "."))
+       break;
+     vector_append(v, l);
+   }
+   if(ferror(input))
+     fatal(errno, "reading %s", tag);
+   if(input != stdin)
+     fclose(input);
+   if(disorder_playlist_lock(getclient(), argv[0])
+      || disorder_playlist_set(getclient(), argv[0], v->vec, v->nvec)
+      || disorder_playlist_unlock(getclient()))
+     exit(EXIT_FAILURE);
+ }
  static const struct command {
    const char *name;
    int min, max;
  } commands[] = {
    { "adduser",        2, 3, cf_adduser, isarg_rights, "USERNAME PASSWORD [RIGHTS]",
                        "Create a new user" },
 +  { "adopt",          1, 1, cf_adopt, 0, "ID",
 +                      "Adopt a randomly picked track" },
    { "allfiles",       1, 2, cf_allfiles, isarg_regexp, "DIR [~REGEXP]",
                        "List all files and directories in DIR" },
    { "authorize",      1, 2, cf_authorize, isarg_rights, "USERNAME [RIGHTS]",
                        "Add TRACKS to the end of the queue" },
    { "playing",        0, 0, cf_playing, 0, "",
                        "Report the playing track" },
+   { "playlist-del",   1, 1, cf_playlist_del, 0, "PLAYLIST",
+                       "Delete a playlist" },
+   { "playlist-get",   1, 1, cf_playlist_get, 0, "PLAYLIST",
+                       "Get the contents of a playlist" },
+   { "playlist-set",   1, 2, cf_playlist_set, isarg_filename, "PLAYLIST [PATH]",
+                       "Set the contents of a playlist" },
+   { "playlists",      0, 0, cf_playlists, 0, "",
+                       "List playlists" },
    { "prefs",          1, 1, cf_prefs, 0, "TRACK",
                        "Display all the preferences for TRACK" },
    { "quack",          0, 0, cf_quack, 0, 0, 0 },
diff --combined disobedience/Makefile.am
@@@ -2,18 -2,20 +2,18 @@@
  # This file is part of DisOrder.
  # Copyright (C) 2006-2008 Richard Kettlewell
  #
 -# This program is free software; you can redistribute it and/or modify
 +# This program is free software: you can redistribute it and/or modify
  # it under the terms of the GNU General Public License as published by
 -# the Free Software Foundation; either version 2 of the License, or
 +# the Free Software Foundation, either version 3 of the License, or
  # (at your option) any later version.
  #
 -# This program is distributed in the hope that it will be useful, but
 -# WITHOUT ANY WARRANTY; without even the implied warranty of
 -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -# General Public License for more details.
 -#
 +# This program is distributed in the hope that it will be useful,
 +# but WITHOUT ANY WARRANTY; without even the implied warranty of
 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +# GNU General Public License for more details.
 +# 
  # You should have received a copy of the GNU General Public License
 -# along with this program; if not, write to the Free Software
 -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -# USA
 +# along with this program.  If not, see <http://www.gnu.org/licenses/>.
  #
  
  bin_PROGRAMS=disobedience
@@@ -27,8 -29,7 +27,8 @@@ disobedience_SOURCES=disobedience.h dis
        recent.c added.c queue-generic.c queue-generic.h queue-menu.c   \
        choose.c choose-menu.c choose-search.c popup.c misc.c           \
        control.c properties.c menu.c log.c progress.c login.c rtp.c    \
 -      help.c ../lib/memgc.c settings.c users.c lookup.c playlists.c
 +      help.c ../lib/memgc.c settings.c users.c lookup.c choose.h      \
-       popup.h
++      popup.h playlists.c
  disobedience_LDADD=../lib/libdisorder.a $(LIBPCRE) $(LIBGC) $(LIBGCRYPT) \
        $(LIBASOUND) $(COREAUDIO) $(LIBDB)
  disobedience_LDFLAGS=$(GTK_LIBS)
@@@ -66,7 -67,6 +66,7 @@@ check-help: al
        unset DISPLAY;./disobedience --version > /dev/null
        unset DISPLAY;./disobedience --help > /dev/null
  
 -CLEANFILES=disobedience.html images.h
 +CLEANFILES=disobedience.html images.h \
 +         *.gcda *.gcov *.gcno *.c.html index.html
  
  export GNUSED
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2006, 2007, 2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file disobedience/disobedience.c
   * @brief Main Disobedience program
@@@ -240,6 -242,7 +240,7 @@@ static gboolean periodic_slow(gpointer 
    /* Update everything to be sure that the connection to the server hasn't
     * mysteriously gone stale on us. */
    all_update();
+   event_raise("periodic-slow", 0);
    /* Recheck RTP status too */
    check_rtp_address(0, 0, 0);
    return TRUE;                          /* don't remove me */
@@@ -282,6 -285,7 +283,7 @@@ static gboolean periodic_fast(gpointer 
      recheck_rights = 0;
    if(recheck_rights)
      check_rights();
+   event_raise("periodic-fast", 0);
    return TRUE;
  }
  
@@@ -479,6 -483,7 +481,7 @@@ int main(int argc, char **argv) 
    disorder_eclient_version(client, version_completed, 0);
    event_register("log-connected", check_rtp_address, 0);
    suppress_actions = 0;
+   playlists_init();
    /* If no password is set yet pop up a login box */
    if(!config->password)
      login_box();
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file disobedience/disobedience.h
   * @brief Header file for Disobedience, the DisOrder GTK+ client
@@@ -250,6 -252,18 +250,18 @@@ void load_settings(void)
  void set_tool_colors(GtkWidget *w);
  void popup_settings(void);
  
+ /* Playlists */
+ void playlists_init(void);
+ void edit_playlists(gpointer callback_data,
+                     guint callback_action,
+                     GtkWidget  *menu_item);
+ extern char **playlists;
+ extern int nplaylists;
+ extern GtkWidget *playlists_widget;
+ extern GtkWidget *playlists_menu;
+ extern GtkWidget *editplaylists_widget;
  #endif /* DISOBEDIENCE_H */
  
  /*
diff --combined disobedience/log.c
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2006, 2007 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file disobedience/log.c
   * @brief State monitoring
@@@ -41,7 -43,12 +41,13 @@@ static void log_state(void *v, unsigne
  static void log_volume(void *v, int l, int r);
  static void log_rescanned(void *v);
  static void log_rights_changed(void *v, rights_type r);
 +static void log_adopted(void *v, const char *id, const char *user);
+ static void log_playlist_created(void *v,
+                                  const char *playlist, const char *sharing);
+ static void log_playlist_modified(void *v,
+                                   const char *playlist, const char *sharing);
+ static void log_playlist_deleted(void *v,
+                                  const char *playlist);
  
  /** @brief Callbacks for server state monitoring */
  const disorder_eclient_log_callbacks log_callbacks = {
    .volume = log_volume,
    .rescanned = log_rescanned,
    .rights_changed = log_rights_changed,
-   .adopted = log_adopted
++  .adopted = log_adopted,
+   .playlist_created = log_playlist_created,
+   .playlist_modified = log_playlist_modified,
+   .playlist_deleted = log_playlist_deleted,
  };
  
  /** @brief Update everything */
@@@ -204,13 -213,23 +213,30 @@@ static void log_rights_changed(void att
    --suppress_actions;
  }
  
 +/** @brief Called when a track is adopted */
 +static void log_adopted(void attribute((unused)) *v,
 +                        const char attribute((unused)) *id,
 +                        const char attribute((unused)) *who) {
 +  event_raise("queue-changed", 0);
 +}
 +
+ static void log_playlist_created(void attribute((unused)) *v,
+                                  const char *playlist,
+                                  const char attribute((unused)) *sharing) {
+   event_raise("playlist-created", (void *)playlist);
+ }
+ static void log_playlist_modified(void attribute((unused)) *v,
+                                   const char *playlist,
+                                   const char attribute((unused)) *sharing) {
+   event_raise("playlist-modified", (void *)playlist);
+ }
+ static void log_playlist_deleted(void attribute((unused)) *v,
+                                  const char *playlist) {
+   event_raise("playlist-deleted", (void *)playlist);
+ }
  /*
  Local Variables:
  c-basic-offset:2
diff --combined disobedience/menu.c
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file disobedience/menu.c
   * @brief Main menu
@@@ -24,6 -26,9 +24,9 @@@
  static GtkWidget *selectall_widget;
  static GtkWidget *selectnone_widget;
  static GtkWidget *properties_widget;
+ GtkWidget *playlists_widget;
+ GtkWidget *playlists_menu;
+ GtkWidget *editplaylists_widget;
  
  /** @brief Main menu widgets */
  GtkItemFactory *mainmenufactory;
@@@ -113,7 -118,7 +116,7 @@@ static void edit_menu_show(GtkWidget at
                               && t->selectnone_sensitive(t->extra));
    }
  }
-    
  /** @brief Fetch version in order to display the about... popup */
  static void about_popup(gpointer attribute((unused)) callback_data,
                          guint attribute((unused)) callback_action,
@@@ -293,6 -298,15 +296,15 @@@ GtkWidget *menubar(GtkWidget *w) 
        0,                                /* item_type */
        0                                 /* extra_data */
      },
+     {
+       (char *)"/Edit/Edit playlists",   /* path */
+       0,                                /* accelerator */
+       edit_playlists,                   /* callback */
+       0,                                /* callback_action */
+       0,                                /* item_type */
+       0                                 /* extra_data */
+     },
+     
      
      {
        (char *)"/Control",               /* path */
        (char *)"<CheckItem>",            /* item_type */
        0                                 /* extra_data */
      },
+     {
+       (char *)"/Control/Activate playlist", /* path */
+       0,                                /* accelerator */
+       0,                                /* callback */
+       0,                                /* callback_action */
+       (char *)"<Branch>",               /* item_type */
+       0                                 /* extra_data */
+     },
      
      {
        (char *)"/Help",                  /* path */
                                                 "<GdisorderMain>/Edit/Deselect all tracks");
    properties_widget = gtk_item_factory_get_widget(mainmenufactory,
                                                  "<GdisorderMain>/Edit/Track properties");
+   playlists_widget = gtk_item_factory_get_item(mainmenufactory,
+                                                "<GdisorderMain>/Control/Activate playlist");
+   playlists_menu = gtk_item_factory_get_widget(mainmenufactory,
+                                                "<GdisorderMain>/Control/Activate playlist");
+   editplaylists_widget = gtk_item_factory_get_widget(mainmenufactory,
+                                                      "<GdisorderMain>/Edit/Edit playlists");
    assert(selectall_widget != 0);
    assert(selectnone_widget != 0);
    assert(properties_widget != 0);
+   assert(playlists_widget != 0);
+   assert(playlists_menu != 0);
+   assert(editplaylists_widget != 0);
  
-   
    GtkWidget *edit_widget = gtk_item_factory_get_widget(mainmenufactory,
                                                         "<GdisorderMain>/Edit");
    g_signal_connect(edit_widget, "show", G_CALLBACK(edit_menu_show), 0);
-   
    event_register("rights-changed", menu_rights_changed, 0);
    users_set_sensitive(0);
    m = gtk_item_factory_get_widget(mainmenufactory,
@@@ -2,21 -2,23 +2,21 @@@
   * This file is part of DisOrder
   * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file disobedience/queue-generic.c
 - * @brief Queue widgets
 + * @brief Disobedience queue widgets
   *
   * This file provides contains code shared between all the queue-like
   * widgets - the queue, the recent list and the added tracks list.
@@@ -127,8 -129,6 +127,8 @@@ const char *column_length(const struct 
      if(last_state & DISORDER_TRACK_PAUSED)
        l = playing_track->sofar;
      else {
 +      if(!last_playing)
 +        return NULL;
        time(&now);
        l = playing_track->sofar + (now - last_playing);
      }
  /** @brief Return the @ref queue_entry corresponding to @p iter
   * @param model Model that owns @p iter
   * @param iter Tree iterator
-  * @return ID string
+  * @return Pointer to queue entry
   */
  struct queue_entry *ql_iter_to_q(GtkTreeModel *model,
                                   GtkTreeIter *iter) {
@@@ -178,14 -178,11 +178,14 @@@ void ql_update_row(struct queue_entry *
      iter = my_iter;
    }
    /* Update all the columns */
 -  for(int col = 0; col < ql->ncolumns; ++col)
 -    gtk_list_store_set(ql->store, iter,
 -                       col, ql->columns[col].value(q,
 -                                                   ql->columns[col].data),
 -                       -1);
 +  for(int col = 0; col < ql->ncolumns; ++col) {
 +    const char *const v = ql->columns[col].value(q,
 +                                                 ql->columns[col].data);
 +    if(v)
 +      gtk_list_store_set(ql->store, iter,
 +                         col, v,
 +                         -1);
 +  }
    gtk_list_store_set(ql->store, iter,
                       ql->ncolumns + QUEUEPOINTER_COLUMN, q,
                       -1);
@@@ -402,6 -399,120 +402,120 @@@ void ql_new_queue(struct queuelike *ql
    --suppress_actions;
  }
  
+ /* Drag and drop has to be figured out experimentally, because it is not well
+  * documented.
+  *
+  * First you get a row-inserted.  The path argument points to the destination
+  * row but this will not yet have had its values set.  The source row is still
+  * present.  AFAICT the iter argument points to the same place.
+  *
+  * Then you get a row-deleted.  The path argument identifies the row that was
+  * deleted.  By this stage the row inserted above has acquired its values.
+  *
+  * A complication is that the deletion will move the inserted row.  For
+  * instance, if you do a drag that moves row 1 down to after the track that was
+  * formerly on row 9, in the row-inserted call it will show up as row 10, but
+  * in the row-deleted call, row 1 will have been deleted thus making the
+  * inserted row be row 9.
+  *
+  * So when we see the row-inserted we have no idea what track to move.
+  * Therefore we stash it until we see a row-deleted.
+  */
+ /** @brief row-inserted callback */
+ static void ql_row_inserted(GtkTreeModel attribute((unused)) *treemodel,
+                             GtkTreePath *path,
+                             GtkTreeIter attribute((unused)) *iter,
+                             gpointer user_data) {
+   struct queuelike *const ql = user_data;
+   if(!suppress_actions) {
+ #if 0
+     char *ps = gtk_tree_path_to_string(path);
+     GtkTreeIter piter[1];
+     gboolean pi = gtk_tree_model_get_iter(treemodel, piter, path);
+     struct queue_entry *pq = pi ? ql_iter_to_q(treemodel, piter) : 0;
+     struct queue_entry *iq = ql_iter_to_q(treemodel, iter);
+     fprintf(stderr, "row-inserted %s path=%s pi=%d pq=%p path=%s iq=%p iter=%s\n",
+             ql->name,
+             ps,
+             pi,
+             pq,
+             (pi
+              ? (pq ? pq->track : "(pq=0)")
+              : "(pi=FALSE)"),
+             iq,
+             iq ? iq->track : "(iq=0)");
+     GtkTreeIter j[1];
+     gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
+     int row = 0;
+     while(jt) {
+       struct queue_entry *q = ql_iter_to_q(treemodel, j);
+       fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
+       jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql->store), j);
+     }
+     g_free(ps);
+ #endif
+     /* Remember an iterator pointing at the insertion target */
+     if(ql->drag_target)
+       gtk_tree_path_free(ql->drag_target);
+     ql->drag_target = gtk_tree_path_copy(path);
+   }
+ }
+ /** @brief row-deleted callback */
+ static void ql_row_deleted(GtkTreeModel attribute((unused)) *treemodel,
+                            GtkTreePath *path,
+                            gpointer user_data) {
+   struct queuelike *const ql = user_data;
+   if(!suppress_actions) {
+ #if 0
+     char *ps = gtk_tree_path_to_string(path);
+     fprintf(stderr, "row-deleted %s path=%s ql->drag_target=%s\n",
+             ql->name, ps, gtk_tree_path_to_string(ql->drag_target));
+     GtkTreeIter j[1];
+     gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
+     int row = 0;
+     while(jt) {
+       struct queue_entry *q = ql_iter_to_q(treemodel, j);
+       fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
+       jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql->store), j);
+     }
+     g_free(ps);
+ #endif
+     if(!ql->drag_target) {
+       error(0, "%s: unsuppressed row-deleted with no row-inserted",
+             ql->name);
+       return;
+     }
+     /* Get the source and destination row numbers. */
+     int srcrow = gtk_tree_path_get_indices(path)[0];
+     int dstrow = gtk_tree_path_get_indices(ql->drag_target)[0];
+     //fprintf(stderr, "srcrow=%d dstrow=%d\n", srcrow, dstrow);
+     /* Note that the source row is computed AFTER the destination has been
+      * inserted, since GTK+ does the insert before the delete.  Therefore if
+      * the source row is south (higher row number) of the destination, it will
+      * be one higher than expected.
+      *
+      * For instance if we drag row 1 to before row 0 we will see row-inserted
+      * for row 0 but then a row-deleted for row 2.
+      */
+     if(srcrow > dstrow)
+       --srcrow;
+     /* Tell the queue implementation */
+     ql->drop(srcrow, dstrow);
+     /* Dispose of stashed data */
+     gtk_tree_path_free(ql->drag_target);
+     ql->drag_target = 0;
+   }
+ }
  /** @brief Initialize a @ref queuelike */
  GtkWidget *init_queuelike(struct queuelike *ql) {
    D(("init_queuelike"));
    g_signal_connect(ql->view, "button-press-event",
                     G_CALLBACK(ql_button_release), ql);
  
+   /* Drag+drop*/
+   if(ql->drop) {
+     gtk_tree_view_set_reorderable(GTK_TREE_VIEW(ql->view), TRUE);
+     g_signal_connect(ql->store,
+                      "row-inserted",
+                      G_CALLBACK(ql_row_inserted), ql);
+     g_signal_connect(ql->store,
+                      "row-deleted",
+                      G_CALLBACK(ql_row_deleted), ql);
+   }
+   
    /* TODO style? */
  
    ql->init();
@@@ -2,21 -2,20 +2,21 @@@
   * This file is part of DisOrder
   * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +/** @file disobedience/queue-generic.h
 + * @brief Disobedience queue widgets
   */
  #ifndef QUEUE_GENERIC_H
  #define QUEUE_GENERIC_H
@@@ -90,6 -89,18 +90,18 @@@ struct queuelike 
  
    /** @brief Menu callbacks */
    struct tabtype tabtype;
+   /** @brief Drag-drop callback, or NULL for no drag+drop
+    * @param src Row to move
+    * @param dst Destination position
+    *
+    * If the rearrangement is impossible then the displayed queue must be put
+    * back.
+    */
+   void (*drop)(int src, int dst);
+   /** @brief Stashed drag target row */
+   GtkTreePath *drag_target;
  };
  
  enum {
@@@ -134,9 -145,6 +146,9 @@@ void ql_remove_activate(GtkMenuItem *me
  int ql_play_sensitive(void *extra);
  void ql_play_activate(GtkMenuItem *menuitem,
                        gpointer user_data);
 +int ql_adopt_sensitive(void *extra);
 +void ql_adopt_activate(GtkMenuItem *menuitem,
 +                       gpointer user_data);
  gboolean ql_button_release(GtkWidget *widget,
                             GdkEventButton *event,
                             gpointer user_data);
diff --combined disobedience/queue.c
@@@ -2,21 -2,20 +2,21 @@@
   * This file is part of DisOrder
   * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +/** @file disobedience/queue.c
 + * @brief Disobedience queue widget
   */
  #include "disobedience.h"
  #include "popup.h"
@@@ -29,10 -28,7 +29,10 @@@ static struct queue_entry *actual_playi
  /** @brief The playing track */
  struct queue_entry *playing_track;
  
 -/** @brief When we last got the playing track */
 +/** @brief When we last got the playing track
 + *
 + * Set to 0 if the timings are currently off due to having just unpaused.
 + */
  time_t last_playing;
  
  static void queue_completed(void *v,
@@@ -44,6 -40,7 +44,6 @@@ static void playing_completed(void *v
  
  /** @brief Called when either the actual queue or the playing track change */
  static void queue_playing_changed(void) {
 -
    /* Check that the playing track isn't in the queue.  There's a race here due
     * to the fact that we issue the two commands at slightly different times.
     * If it goes wrong we re-issue and try again, so that we never offer up an
@@@ -69,6 -66,7 +69,6 @@@
      playing_track = NULL;
      q = actual_queue;
    }
 -  time(&last_playing);          /* for column_length() */
    ql_new_queue(&ql_queue, q);
    /* Tell anyone who cares */
    event_raise("queue-list-changed", q);
@@@ -97,7 -95,6 +97,7 @@@ static void playing_completed(void attr
    }
    actual_playing_track = q;
    queue_playing_changed();
 +  time(&last_playing);
  }
  
  /** @brief Schedule an update to the queue
@@@ -121,9 -118,6 +121,9 @@@ static void playing_changed(const char 
                              void  attribute((unused)) *callbackdata) {
    D(("playing_changed"));
    gtk_label_set_text(GTK_LABEL(report_label), "updating playing track");
 +  /* Setting last_playing=0 means that we don't know what the correct value
 +   * is right now, e.g. because things have been deranged by a pause. */
 +  last_playing = 0;
    disorder_eclient_playing(client, playing_completed, 0);
  }
  
@@@ -152,6 -146,61 +152,61 @@@ static void queue_init(void) 
    g_timeout_add(1000/*ms*/, playing_periodic, 0);
  }
  
+ static void queue_move_completed(void attribute((unused)) *v,
+                                  const char *err) {
+   if(err) {
+     popup_protocol_error(0, err);
+     return;
+   }
+   /* The log should tell us the queue changed so we do no more here */
+ }
+ /** @brief Called when drag+drop completes */
+ static void queue_drop(int src, int dst) {
+   struct queue_entry *sq, *dq;
+   int n;
+   //fprintf(stderr, "queue_drop %d -> %d\n", src, dst);
+   if(playing_track) {
+     /* If there's a playing track then you can't drag it anywhere  */
+     if(src == 0) {
+       //fprintf(stderr, "cannot drag playing track\n");
+       queue_playing_changed();
+       return;
+     }
+     /* If you try to drop before the playing track we assume you missed and
+      * mean after instead */
+     if(!dst)
+       dst = 1;
+     //fprintf(stderr, "...adjusted to %d -> %d\n\n", src, dst);
+   }
+   /* Find the entry to move */
+   for(n = 0, sq = ql_queue.q; n < src; ++n)
+     sq = sq->next;
+   /*fprintf(stderr, "source=%s (%s)\n",
+           sq->id, sq->track);*/
+   const int after = dst - 1;
+   if(after == -1)
+     dq = 0;
+   else
+     /* Find the entry to insert after */
+     for(n = 0, dq = ql_queue.q; n < after; ++n)
+       dq = dq->next;
+   if(dq == playing_track)
+     dq = 0;
+ #if 0
+   if(dq)
+     fprintf(stderr, "after=%s (%s)\n",
+             dq->id, dq->track);
+   else
+     fprintf(stderr, "after=NULL\n");
+ #endif
+   disorder_eclient_moveafter(client,
+                              dq ? dq->id : "",
+                              1, &sq->id,
+                              queue_move_completed, NULL);
+ }
  /** @brief Columns for the queue */
  static const struct queue_column queue_columns[] = {
    { "When",   column_when,     0,        COL_RIGHT },
@@@ -169,7 -218,6 +224,7 @@@ static struct menuitem queue_menuitems[
    { "Deselect all tracks", ql_selectnone_activate, ql_selectnone_sensitive, 0, 0 },
    { "Scratch playing track", ql_scratch_activate, ql_scratch_sensitive, 0, 0 },
    { "Remove track from queue", ql_remove_activate, ql_remove_sensitive, 0, 0 },
 +  { "Adopt track", ql_adopt_activate, ql_adopt_sensitive, 0, 0 },
  };
  
  struct queuelike ql_queue = {
    .columns = queue_columns,
    .ncolumns = sizeof queue_columns / sizeof *queue_columns,
    .menuitems = queue_menuitems,
-   .nmenuitems = sizeof queue_menuitems / sizeof *queue_menuitems
+   .nmenuitems = sizeof queue_menuitems / sizeof *queue_menuitems,
+   .drop = queue_drop
  };
  
- /* Drag and drop has to be figured out experimentally, because it is not well
-  * documented.
-  *
-  * First you get a row-inserted.  The path argument points to the destination
-  * row but this will not yet have had its values set.  The source row is still
-  * present.  AFAICT the iter argument points to the same place.
-  *
-  * Then you get a row-deleted.  The path argument identifies the row that was
-  * deleted.  By this stage the row inserted above has acquired its values.
-  *
-  * A complication is that the deletion will move the inserted row.  For
-  * instance, if you do a drag that moves row 1 down to after the track that was
-  * formerly on row 9, in the row-inserted call it will show up as row 10, but
-  * in the row-deleted call, row 1 will have been deleted thus making the
-  * inserted row be row 9.
-  *
-  * So when we see the row-inserted we have no idea what track to move.
-  * Therefore we stash it until we see a row-deleted.
-  */
- /** @brief Target row for drag */
- static int queue_drag_target = -1;
- static void queue_move_completed(void attribute((unused)) *v,
-                                  const char *err) {
-   if(err) {
-     popup_protocol_error(0, err);
-     return;
-   }
-   /* The log should tell us the queue changed so we do no more here */
- }
- static void queue_row_deleted(GtkTreeModel *treemodel,
-                               GtkTreePath *path,
-                               gpointer attribute((unused)) user_data) {
-   if(!suppress_actions) {
- #if 0
-     char *ps = gtk_tree_path_to_string(path);
-     fprintf(stderr, "row-deleted path=%s queue_drag_target=%d\n",
-             ps, queue_drag_target);
-     GtkTreeIter j[1];
-     gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
-     int row = 0;
-     while(jt) {
-       struct queue_entry *q = ql_iter_to_q(treemodel, j);
-       fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
-       jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql_queue.store), j);
-     }
-     g_free(ps);
- #endif
-     if(queue_drag_target < 0) {
-       error(0, "unsuppressed row-deleted with no row-inserted");
-       return;
-     }
-     int drag_source = gtk_tree_path_get_indices(path)[0];
-     /* If the drag is downwards (=towards higher row numbers) then the target
-      * will have been moved upwards (=towards lower row numbers) by one row. */
-     if(drag_source < queue_drag_target)
-       --queue_drag_target;
-     
-     /* Find the track to move */
-     GtkTreeIter src[1];
-     gboolean srcv = gtk_tree_model_iter_nth_child(treemodel, src, NULL,
-                                                   queue_drag_target);
-     if(!srcv) {
-       error(0, "cannot get iterator to drag target %d", queue_drag_target);
-       queue_playing_changed();
-       queue_drag_target = -1;
-       return;
-     }
-     struct queue_entry *srcq = ql_iter_to_q(treemodel, src);
-     assert(srcq);
-     //fprintf(stderr, "move %s %s\n", srcq->id, srcq->track);
-     
-     /* Don't allow the currently playing track to be moved.  As above, we put
-      * the queue back into the right order straight away. */
-     if(srcq == playing_track) {
-       //fprintf(stderr, "cannot move currently playing track\n");
-       queue_playing_changed();
-       queue_drag_target = -1;
-       return;
-     }
-     /* Find the destination */
-     struct queue_entry *dstq;
-     if(queue_drag_target) {
-       GtkTreeIter dst[1];
-       gboolean dstv = gtk_tree_model_iter_nth_child(treemodel, dst, NULL,
-                                                     queue_drag_target - 1);
-       if(!dstv) {
-         error(0, "cannot get iterator to drag target predecessor %d",
-               queue_drag_target - 1);
-         queue_playing_changed();
-         queue_drag_target = -1;
-         return;
-       }
-       dstq = ql_iter_to_q(treemodel, dst);
-       assert(dstq);
-       if(dstq == playing_track)
-         dstq = 0;
-     } else
-       dstq = 0;
-     /* NB if the user attempts to move a queued track before the currently
-      * playing track we assume they just missed a bit, and put it after. */
-     //fprintf(stderr, " target %s %s\n", dstq ? dstq->id : "(none)", dstq ? dstq->track : "(none)");
-     /* Now we know what is to be moved.  We need to know the preceding queue
-      * entry so we can move it. */
-     disorder_eclient_moveafter(client,
-                                dstq ? dstq->id : "",
-                                1, &srcq->id,
-                                queue_move_completed, NULL);
-     queue_drag_target = -1;
-   }
- }
- static void queue_row_inserted(GtkTreeModel attribute((unused)) *treemodel,
-                                GtkTreePath *path,
-                                GtkTreeIter attribute((unused)) *iter,
-                                gpointer attribute((unused)) user_data) {
-   if(!suppress_actions) {
- #if 0
-     char *ps = gtk_tree_path_to_string(path);
-     GtkTreeIter piter[1];
-     gboolean pi = gtk_tree_model_get_iter(treemodel, piter, path);
-     struct queue_entry *pq = pi ? ql_iter_to_q(treemodel, piter) : 0;
-     struct queue_entry *iq = ql_iter_to_q(treemodel, iter);
-     fprintf(stderr, "row-inserted path=%s pi=%d pq=%p path=%s iq=%p iter=%s\n",
-             ps,
-             pi,
-             pq,
-             (pi
-              ? (pq ? pq->track : "(pq=0)")
-              : "(pi=FALSE)"),
-             iq,
-             iq ? iq->track : "(iq=0)");
-     GtkTreeIter j[1];
-     gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
-     int row = 0;
-     while(jt) {
-       struct queue_entry *q = ql_iter_to_q(treemodel, j);
-       fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
-       jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql_queue.store), j);
-     }
-     g_free(ps);
- #endif
-     queue_drag_target = gtk_tree_path_get_indices(path)[0];
-   }
- }
  /** @brief Called when a key is pressed in the queue tree view */
  static gboolean queue_key_press(GtkWidget attribute((unused)) *widget,
                                  GdkEventKey *event,
  GtkWidget *queue_widget(void) {
    GtkWidget *const w = init_queuelike(&ql_queue);
  
-   /* Enable drag+drop */
-   gtk_tree_view_set_reorderable(GTK_TREE_VIEW(ql_queue.view), TRUE);
-   g_signal_connect(ql_queue.store,
-                    "row-inserted",
-                    G_CALLBACK(queue_row_inserted), &ql_queue);
-   g_signal_connect(ql_queue.store,
-                    "row-deleted",
-                    G_CALLBACK(queue_row_deleted), &ql_queue);
    /* Catch keypresses */
    g_signal_connect(ql_queue.view, "key-press-event",
                     G_CALLBACK(queue_key_press), &ql_queue);
diff --combined doc/disorder.1.in
@@@ -1,18 -1,20 +1,18 @@@
  .\"
  .\" Copyright (C) 2004-2008 Richard Kettlewell
  .\"
 -.\" This program is free software; you can redistribute it and/or modify
 +.\" This program is free software: you can redistribute it and/or modify
  .\" it under the terms of the GNU General Public License as published by
 -.\" the Free Software Foundation; either version 2 of the License, or
 +.\" the Free Software Foundation, either version 3 of the License, or
  .\" (at your option) any later version.
 -.\"
 -.\" This program is distributed in the hope that it will be useful, but
 -.\" WITHOUT ANY WARRANTY; without even the implied warranty of
 -.\" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -.\" General Public License for more details.
 -.\"
 +.\" 
 +.\" This program is distributed in the hope that it will be useful,
 +.\" but WITHOUT ANY WARRANTY; without even the implied warranty of
 +.\" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +.\" GNU General Public License for more details.
 +.\" 
  .\" You should have received a copy of the GNU General Public License
 -.\" along with this program; if not, write to the Free Software
 -.\" Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -.\" USA
 +.\" along with this program.  If not, see <http://www.gnu.org/licenses/>.
  .\"
  .TH disorder 1
  .SH NAME
@@@ -62,10 -64,6 +62,10 @@@ Create a new user
  If \fIRIGHTS\fR is not specified then the \fBdefault_rights\fR
  setting from the server's configuration file applies.
  .TP
 +.B adopt \fIID\fR
 +Adopts track \fIID\fR (in the queue).
 +The track will show up as submitted by the calling user.
 +.TP
  .B authorize \fIUSERNAME\fR [\fIRIGHTS\fR]
  Create user \fIUSERNAME\fR with a random password.
  User \fIUSERNAME\fR must be a UNIX login user (not just any old string).
@@@ -152,6 -150,23 +152,23 @@@ Add \fITRACKS\fR to the end of the queu
  .B playing
  Report the currently playing track.
  .TP
+ .B playlist-del \fIPLAYLIST\fR
+ Deletes playlist \fIPLAYLIST\fR.
+ .TP
+ .B playlist-get \fIPLAYLIST\fR
+ Gets the contents of playlist \fIPLAYLIST\fR.
+ .TP
+ .B playlist-set \fIPLAYLIST\fR [\fIPATH\fR]
+ Set the contents of playlist \fIPLAYLIST\fR.
+ If an absolute path name is specified then the track list is read from
+ that filename.
+ Otherwise the track list is read from standard input.
+ In either case, the list is terminated either by end of file or by a line
+ containing a single ".".
+ .TP
+ .B playlists
+ Lists known playlists (in no particular order).
+ .TP
  .B prefs \fITRACK\fR
  Display all the preferences for \fITRACK\fR.
  See \fBdisorder_preferences\fR (5).
@@@ -1,18 -1,20 +1,18 @@@
  .\"
  .\" Copyright (C) 2004-2008 Richard Kettlewell
  .\"
 -.\" This program is free software; you can redistribute it and/or modify
 +.\" This program is free software: you can redistribute it and/or modify
  .\" it under the terms of the GNU General Public License as published by
 -.\" the Free Software Foundation; either version 2 of the License, or
 +.\" the Free Software Foundation, either version 3 of the License, or
  .\" (at your option) any later version.
 -.\"
 -.\" This program is distributed in the hope that it will be useful, but
 -.\" WITHOUT ANY WARRANTY; without even the implied warranty of
 -.\" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -.\" General Public License for more details.
 -.\"
 +.\" 
 +.\" This program is distributed in the hope that it will be useful,
 +.\" but WITHOUT ANY WARRANTY; without even the implied warranty of
 +.\" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +.\" GNU General Public License for more details.
 +.\" 
  .\" You should have received a copy of the GNU General Public License
 -.\" along with this program; if not, write to the Free Software
 -.\" Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -.\" USA
 +.\" along with this program.  If not, see <http://www.gnu.org/licenses/>.
  .\"
  .TH disorder_protocol 5
  .SH NAME
@@@ -38,6 -40,15 +38,15 @@@ that comments are prohibited
  Bodies borrow their syntax from RFC821; they consist of zero or more ordinary
  lines, with any initial full stop doubled up, and are terminated by a line
  consisting of a full stop and a line feed.
+ .PP
+ Commands only have a body if explicitly stated below.
+ If they do have a body then the body should always be sent immediately;
+ unlike (for instance) the SMTP "DATA" command there is no intermediate step
+ where the server asks for the body to be sent.
+ .PP
+ Replies also only have a body if stated below.
+ The presence of a reply body can always be inferred from the response code;
+ if the last digit is a 3 then a body is present, otherwise it is not.
  .SH COMMANDS
  Commands always have a command name as the first field of the line; responses
  always have a 3-digit response code as the first field.
@@@ -47,8 -58,6 +56,6 @@@ All commands require the connection to 
  stated otherwise.
  If not stated otherwise, the \fBread\fR right is sufficient to execute
  the command.
- .PP
- Neither commands nor responses have a body unless stated otherwise.
  .TP
  .B adduser \fIUSERNAME PASSWORD \fR[\fIRIGHTS\fR]
  Create a new user with the given username and password.
@@@ -57,10 -66,6 +64,10 @@@ then the \fBdefault_rights\fR setting a
  Requires the \fBadmin\fR right, and only works on local
  connections.
  .TP
 +.B adopt \fIID\fR
 +Adopts a randomly picked track, leaving it in a similar state to if it was
 +picked by this user.  Requires the \fBplay\fR right.
 +.TP
  .B allfiles \fIDIRECTORY\fR [\fIREGEXP\fR]
  List all the files and directories in \fIDIRECTORY\fR in a response body.
  If \fIREGEXP\fR is present only matching files and directories are returned.
@@@ -208,6 -213,43 +215,43 @@@ track information (see below)
  .IP
  If the response is \fB259\fR then nothing is playing.
  .TP
+ .B playlist-delete \fIPLAYLIST\fR
+ Delete a playlist.
+ Requires permission to modify that playlist and the \fBplay\fR right.
+ .TP
+ .B playlist-get \fIPLAYLIST\fR
+ Get the contents of a playlist, in a response body.
+ Requires permission to read that playlist and the \fBread\fR right.
+ .TP
+ .B playlist-get-share \fIPLAYLIST\fR
+ Get the sharing status of a playlist.
+ The result will be \fBpublic\fR, \fBprivate\fR or \fBshared\fR.
+ Requires permission to read that playlist and the \fBread\fR right.
+ .TP
+ .B playlist-lock \fIPLAYLIST\fR
+ Lock a playlist.
+ Requires permission to modify that playlist and the \fBplay\fR right.
+ Only one playlist may be locked at a time on a given connection and the lock
+ automatically expires when the connection is closed.
+ .TP
+ .B playlist-set \fIPLAYLIST\fR
+ Set the contents of a playlist.
+ The new contents should be supplied in a command body.
+ Requires permission to modify that playlist and the \fBplay\fR right.
+ The playlist must be locked.
+ .TP
+ .B playlist-set-share \fIPLAYLIST\fR \fISHARE\fR
+ Set the sharing status of a playlist to
+ \fBpublic\fR, \fBprivate\fR or \fBshared\fR.
+ Requires permission to modify that playlist and the \fBplay\fR right.
+ .TP
+ .B playlist-unlock\fR
+ Unlock the locked playlist.
+ .TP
+ .B playlists
+ List all playlists that this connection has permission to read.
+ Requires the \fBread\fR right.
+ .TP
  .B prefs \fBTRACK\fR
  Send back the preferences for \fITRACK\fR in a response body.
  Each line of the response has the usual line syntax, the first field being the
@@@ -499,26 -541,6 +543,26 @@@ The time the track was played at
  .B scratched
  The user that scratched the track.
  .TP
 +.B origin
 +The origin of the track.  Valid origins are:
 +.RS
 +.TP 12
 +.B adopted
 +The track was originally randomly picked but has been adopted by a user.
 +.TP
 +.B picked
 +The track was picked by a user.
 +.TP
 +.B random
 +The track was randomly picked.
 +.TP
 +.B scheduled
 +The track was played from a scheduled action.
 +.TP
 +.B scratch
 +The track is a scratch sound.
 +.RE
 +.TP
  .B state
  The current track state.
  Valid states are:
  .B failed
  The player failed (exited with nonzero status but wasn't scratched).
  .TP
 -.B isscratch
 -The track is actually a scratch.
 -.TP
 -.B no_player
 -No player could be found for the track.
 -.TP
  .B ok
  The track was played without any problems.
  .TP
@@@ -536,9 -564,6 +580,9 @@@ The track was scratched
  .B started
  The track is currently playing.
  .TP
 +.B paused
 +Track is playing but paused.
 +.TP
  .B unplayed
  In the queue, hasn't been played yet.
  .TP
@@@ -557,9 -582,6 +601,9 @@@ The time the track was added to the que
  .TP
  .B wstat
  The wait status of the player in decimal.
 +.PP
 +Note that \fBorigin\fR is new with DisOrder 4.3, and obsoletes some old
 +\fBstate\fR values.
  .SH NOTES
  Times are decimal integers using the server's \fBtime_t\fR.
  .PP
@@@ -577,9 -599,6 +621,9 @@@ keyword followed by (optionally) parame
  The parameters are quoted in the usual DisOrder way.
  Currently the following keywords are used:
  .TP
 +.B adopted \fIID\fR \fIUSERNAME\fR
 +\fIUSERNAME\fR adopted track \fIID\fR.
 +.TP
  .B completed \fITRACK\fR
  Completed playing \fITRACK\fR
  .TP
@@@ -593,6 -612,21 +637,21 @@@ Further details aren't included any mor
  .B playing \fITRACK\fR [\fIUSERNAME\fR]
  Started playing \fITRACK\fR.
  .TP
+ .B playlist_created \fIPLAYLIST\fR \fISHARING\fR
+ Sent when a playlist is created.
+ For private playlists this is intended to be sent only to the owner (but
+ this is not currently implemented).
+ .TP
+ .B playlist_deleted \fIPLAYLIST\fR
+ Sent when a playlist is deleted.
+ For private playlists this is intended to be sent only to the owner (but
+ this is not currently implemented).
+ .TP
+ .B playlist_modified \fIPLAYLIST\fR \fISHARING\fR
+ Sent when a playlist is modified (either its contents or its sharing status).
+ For private playlists this is intended to be sent only to the owner (but
+ this is not currently implemented).
+ .TP
  .B queue \fIQUEUE-ENTRY\fR...
  Added \fITRACK\fR to the queue.
  .TP
diff --combined lib/Makefile.am
@@@ -2,18 -2,20 +2,18 @@@
  # This file is part of DisOrder.
  # Copyright (C) 2004-2008 Richard Kettlewell
  #
 -# This program is free software; you can redistribute it and/or modify
 +# This program is free software: you can redistribute it and/or modify
  # it under the terms of the GNU General Public License as published by
 -# the Free Software Foundation; either version 2 of the License, or
 +# the Free Software Foundation, either version 3 of the License, or
  # (at your option) any later version.
 -#
 -# This program is distributed in the hope that it will be useful, but
 -# WITHOUT ANY WARRANTY; without even the implied warranty of
 -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -# General Public License for more details.
 -#
 +# 
 +# This program is distributed in the hope that it will be useful,
 +# but WITHOUT ANY WARRANTY; without even the implied warranty of
 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +# GNU General Public License for more details.
 +# 
  # You should have received a copy of the GNU General Public License
 -# along with this program; if not, write to the Free Software
 -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -# USA
 +# along with this program.  If not, see <http://www.gnu.org/licenses/>.
  #
  
  noinst_LIBRARIES=libdisorder.a
@@@ -73,12 -75,12 +73,13 @@@ libdisorder_a_SOURCES=charset.c charset
        sink.c sink.h                                   \
        speaker-protocol.c speaker-protocol.h           \
        split.c split.h                                 \
 +      strptime.c strptime.h                           \
        syscalls.c syscalls.h                           \
        common.h                                        \
        table.c table.h                                 \
        timeval.h                                       \
        $(TRACKDB) trackdb.h trackdb-int.h              \
+       trackdb-playlists.c                             \
        trackname.c trackorder.c trackname.h            \
        tracksort.c                                     \
        url.h url.c                                     \
@@@ -107,12 -109,7 +108,12 @@@ versionstring.h: version-string ${top_s
  
  definitions.h: Makefile
        rm -f $@.new
 -      echo "#define PKGLIBDIR \"${pkglibdir}\"" > $@.new
 +      echo "/** @file lib/definitions.h" >> $@.new
 +      echo " * @brief Definitions exported from makefile" >> $@.new
 +      echo " *" >> $@.new
 +      echo " * DO NOT EDIT." >> $@.new
 +      echo " */" >> $@.new
 +      echo "#define PKGLIBDIR \"${pkglibdir}\"" >> $@.new
        echo "#define PKGCONFDIR \"${sysconfdir}/\"PACKAGE" >> $@.new
        echo "#define PKGSTATEDIR \"${localstatedir}/\"PACKAGE" >> $@.new
        echo "#define PKGDATADIR \"${pkgdatadir}/\"" >> $@.new
@@@ -134,6 -131,6 +135,6 @@@ rebuild-unicode
        mv $@.new $@
  
  CLEANFILES=definitions.h definitions.h.new version-string versionstring.h \
 -         *.gcda *.gcov *.gcno
 +         *.gcda *.gcov *.gcno *.c.html index.html
  
  EXTRA_DIST=trackdb.c trackdb-stub.c
diff --combined lib/client.c
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2004-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/client.c
   * @brief Simple C client
@@@ -153,6 -155,8 +153,8 @@@ static int check_response(disorder_clie
   * @param c Client
   * @param rp Where to store result, or NULL
   * @param cmd Command
+  * @param body Body or NULL
+  * @param nbody Length of body or -1
   * @param ap Arguments (UTF-8), terminated by (char *)0
   * @return 0 on success, non-0 on error
   *
   *
   * NB that the response will NOT be converted to the local encoding
   * nor will quotes be stripped.  See dequote().
+  *
+  * If @p body is not NULL then the body is sent immediately after the
+  * command.  @p nbody should be the number of lines or @c -1 to count
+  * them if @p body is NULL-terminated.
+  *
+  * Usually you would call this via one of the following interfaces:
+  * - disorder_simple()
+  * - disorder_simple_body()
+  * - disorder_simple_list()
   */
  static int disorder_simple_v(disorder_client *c,
                             char **rp,
-                            const char *cmd, va_list ap) {
+                            const char *cmd,
+                              char **body, int nbody,
+                              va_list ap) {
    const char *arg;
    struct dynstr d;
  
      dynstr_append(&d, '\n');
      dynstr_terminate(&d);
      D(("command: %s", d.vec));
-     if(fputs(d.vec, c->fpout) < 0 || fflush(c->fpout)) {
-       byte_xasprintf((char **)&c->last, "write error: %s", strerror(errno));
-       error(errno, "error writing to %s", c->ident);
-       return -1;
+     if(fputs(d.vec, c->fpout) < 0)
+       goto write_error;
+     if(body) {
+       if(nbody < 0)
+         for(nbody = 0; body[nbody]; ++nbody)
+           ;
+       for(int n = 0; n < nbody; ++n) {
+         if(body[n][0] == '.')
+           if(fputc('.', c->fpout) < 0)
+             goto write_error;
+         if(fputs(body[n], c->fpout) < 0)
+           goto write_error;
+         if(fputc('\n', c->fpout) < 0)
+           goto write_error;
+       }
+       if(fputs(".\n", c->fpout) < 0)
+         goto write_error;
      }
+     if(fflush(c->fpout))
+       goto write_error;
    }
    return check_response(c, rp);
+ write_error:
+   byte_xasprintf((char **)&c->last, "write error: %s", strerror(errno));
+   error(errno, "error writing to %s", c->ident);
+   return -1;
  }
  
  /** @brief Issue a command and parse a simple response
@@@ -218,7 -252,30 +250,30 @@@ static int disorder_simple(disorder_cli
    int ret;
  
    va_start(ap, cmd);
-   ret = disorder_simple_v(c, rp, cmd, ap);
+   ret = disorder_simple_v(c, rp, cmd, 0, 0, ap);
+   va_end(ap);
+   return ret;
+ }
+ /** @brief Issue a command with a body and parse a simple response
+  * @param c Client
+  * @param rp Where to store result, or NULL (UTF-8)
+  * @param body Pointer to body
+  * @param nbody Size of body
+  * @param cmd Command
+  * @return 0 on success, non-0 on error
+  *
+  * See disorder_simple().
+  */
+ static int disorder_simple_body(disorder_client *c,
+                                 char **rp,
+                                 char **body, int nbody,
+                                 const char *cmd, ...) {
+   va_list ap;
+   int ret;
+   va_start(ap, cmd);
+   ret = disorder_simple_v(c, rp, cmd, body, nbody, ap);
    va_end(ap);
    return ret;
  }
@@@ -670,6 -727,8 +725,8 @@@ static int readlist(disorder_client *c
   * *)0.  They should be in UTF-8.
   *
   * 5xx responses count as errors.
+  *
+  * See disorder_simple().
   */
  static int disorder_simple_list(disorder_client *c,
                                char ***vecp, int *nvecp,
    int ret;
  
    va_start(ap, cmd);
-   ret = disorder_simple_v(c, 0, cmd, ap);
+   ret = disorder_simple_v(c, 0, cmd, 0, 0, ap);
    va_end(ap);
    if(ret) return ret;
    return readlist(c, vecp, nvecp);
@@@ -1302,15 -1361,103 +1359,112 @@@ int disorder_schedule_add(disorder_clie
    return rc;
  }
  
 +/** @brief Adopt a track
 + * @param c Client
 + * @param id Track ID to adopt
 + * @return 0 on success, non-0 on error
 + */
 +int disorder_adopt(disorder_client *c, const char *id) {
 +  return disorder_simple(c, 0, "adopt", id, (char *)0);
 +}
 +
+ /** @brief Delete a playlist
+  * @param c Client
+  * @param playlist Playlist to delete
+  * @return 0 on success, non-0 on error
+  */
+ int disorder_playlist_delete(disorder_client *c,
+                              const char *playlist) {
+   return disorder_simple(c, 0, "playlist-delete", playlist, (char *)0);
+ }
+ /** @brief Get the contents of a playlist
+  * @param c Client
+  * @param playlist Playlist to get
+  * @param tracksp Where to put list of tracks
+  * @param ntracksp Where to put count of tracks
+  * @return 0 on success, non-0 on error
+  */
+ int disorder_playlist_get(disorder_client *c, const char *playlist,
+                           char ***tracksp, int *ntracksp) {
+   return disorder_simple_list(c, tracksp, ntracksp,
+                               "playlist-get", playlist, (char *)0);
+ }
+ /** @brief List all readable playlists
+  * @param c Client
+  * @param playlistsp Where to put list of playlists
+  * @param nplaylistsp Where to put count of playlists
+  * @return 0 on success, non-0 on error
+  */
+ int disorder_playlists(disorder_client *c,
+                        char ***playlistsp, int *nplaylistsp) {
+   return disorder_simple_list(c, playlistsp, nplaylistsp,
+                               "playlists", (char *)0);
+ }
+ /** @brief Get the sharing status of a playlist
+  * @param c Client
+  * @param playlist Playlist to inspect
+  * @param sharep Where to put sharing status
+  * @return 0 on success, non-0 on error
+  *
+  * Possible @p sharep values are @c public, @c private and @c shared.
+  */
+ int disorder_playlist_get_share(disorder_client *c, const char *playlist,
+                                 char **sharep) {
+   return disorder_simple(c, sharep,
+                          "playlist-get-share", playlist, (char *)0);
+ }
+ /** @brief Get the sharing status of a playlist
+  * @param c Client
+  * @param playlist Playlist to modify
+  * @param share New sharing status
+  * @return 0 on success, non-0 on error
+  *
+  * Possible @p share values are @c public, @c private and @c shared.
+  */
+ int disorder_playlist_set_share(disorder_client *c, const char *playlist,
+                                 const char *share) {
+   return disorder_simple(c, 0,
+                          "playlist-set-share", playlist, share, (char *)0);
+ }
+ /** @brief Lock a playlist for modifications
+  * @param c Client
+  * @param playlist Playlist to lock
+  * @return 0 on success, non-0 on error
+  */
+ int disorder_playlist_lock(disorder_client *c, const char *playlist) {
+   return disorder_simple(c, 0,
+                          "playlist-lock", playlist, (char *)0);
+ }
+ /** @brief Unlock the locked playlist
+  * @param c Client
+  * @return 0 on success, non-0 on error
+  */
+ int disorder_playlist_unlock(disorder_client *c) {
+   return disorder_simple(c, 0,
+                          "playlist-unlock", (char *)0);
+ }
+ /** @brief Set the contents of a playlst
+  * @param c Client
+  * @param playlist Playlist to modify
+  * @param tracks List of tracks
+  * @param ntracks Length of @p tracks (or -1 to count up to the first NULL)
+  * @return 0 on success, non-0 on error
+  */
+ int disorder_playlist_set(disorder_client *c,
+                           const char *playlist,
+                           char **tracks,
+                           int ntracks) {
+   return disorder_simple_body(c, 0, tracks, ntracks,
+                               "playlist-set", playlist, (char *)0);
+ }
  /*
  Local Variables:
  c-basic-offset:2
diff --combined lib/client.h
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2004-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/client.h
   * @brief Simple C client
@@@ -131,7 -133,22 +131,23 @@@ int disorder_schedule_add(disorder_clie
                          const char *priority,
                          const char *action,
                          ...);
 +int disorder_adopt(disorder_client *c, const char *id);
+ int disorder_playlist_delete(disorder_client *c,
+                              const char *playlist);
+ int disorder_playlist_get(disorder_client *c, const char *playlist,
+                           char ***tracksp, int *ntracksp);
+ int disorder_playlists(disorder_client *c,
+                        char ***playlistsp, int *nplaylists);
+ int disorder_playlist_get_share(disorder_client *c, const char *playlist,
+                                 char **sharep);
+ int disorder_playlist_set_share(disorder_client *c, const char *playlist,
+                                 const char *share);
+ int disorder_playlist_lock(disorder_client *c, const char *playlist);
+ int disorder_playlist_unlock(disorder_client *c);
+ int disorder_playlist_set(disorder_client *c,
+                           const char *playlist,
+                           char **tracks,
+                           int ntracks);
  
  #endif /* CLIENT_H */
  
diff --combined lib/configuration.c
@@@ -3,18 -3,20 +3,18 @@@
   * Copyright (C) 2004-2008 Richard Kettlewell
   * Portions copyright (C) 2007 Mark Wooding
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/configuration.c
   * @brief Configuration file support
@@@ -950,6 -952,8 +950,8 @@@ static const struct conf conf[] = 
    { C(noticed_history),  &type_integer,          validate_positive },
    { C(password),         &type_string,           validate_any },
    { C(player),           &type_stringlist_accum, validate_player },
+   { C(playlist_lock_timeout), &type_integer,     validate_positive },
+   { C(playlist_max) ,    &type_integer,          validate_positive },
    { C(plugins),          &type_string_accum,     validate_isdir },
    { C(prefsync),         &type_integer,          validate_positive },
    { C(queue_pad),        &type_integer,          validate_positive },
@@@ -1195,8 -1199,9 +1197,10 @@@ static struct config *config_default(vo
    c->new_max = 100;
    c->reminder_interval = 600;         /* 10m */
    c->new_bias_age = 7 * 86400;                /* 1 week */
 -  c->new_bias = 9000000;              /* 100 times the base weight */
 +  c->new_bias = 4500000;              /* 50 times the base weight */
 +  c->sox_generation = DEFAULT_SOX_GENERATION;
+   c->playlist_max = INT_MAX;            /* effectively no limit */
+   c->playlist_lock_timeout = 10;        /* 10s */
    /* Default stopwords */
    if(config_set(&cs, (int)NDEFAULT_STOPWORDS, (char **)default_stopwords))
      exit(1);
@@@ -1247,7 -1252,7 +1251,7 @@@ static void config_postdefaults(struct 
    int n;
  
    static const char *namepart[][4] = {
 -    { "title",  "/([0-9]+ *[-:] *)?([^/]+)\\.[a-zA-Z0-9]+$", "$2", "display" },
 +    { "title",  "/([0-9]+ *[-:]? *)?([^/]+)\\.[a-zA-Z0-9]+$", "$2", "display" },
      { "title",  "/([^/]+)\\.[a-zA-Z0-9]+$",           "$1", "sort" },
      { "album",  "/([^/]+)/[^/]+$",                    "$1", "*" },
      { "artist", "/([^/]+)/[^/]+/[^/]+$",              "$1", "*" },
  #define NNAMEPART (int)(sizeof namepart / sizeof *namepart)
  
    static const char *transform[][5] = {
 -    { "track", "^.*/([0-9]+ *[-:] *)?([^/]+)\\.[a-zA-Z0-9]+$", "$2", "display", "" },
 +    { "track", "^.*/([0-9]+ *[-:]? *)?([^/]+)\\.[a-zA-Z0-9]+$", "$2", "display", "" },
      { "track", "^.*/([^/]+)\\.[a-zA-Z0-9]+$",           "$1", "sort", "" },
      { "dir",   "^.*/([^/]+)$",                          "$1", "*", "" },
      { "dir",   "^(the) ([^/]*)",                        "$2, $1", "sort", "i", },
diff --combined lib/configuration.h
@@@ -3,18 -3,20 +3,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2004-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/configuration.h
   * @brief Configuration file support
@@@ -183,6 -185,12 +183,12 @@@ struct config 
     */
    int api;
  
+   /** @brief Maximum size of a playlist */
+   long playlist_max;
+   /** @brief Maximum lifetime of a playlist lock */
+   long playlist_lock_timeout;
  /* These values had better be non-negative */
  #define BACKEND_ALSA 0                        /**< Use ALSA (Linux only) */
  #define BACKEND_COMMAND 1             /**< Execute a command */
diff --combined lib/eclient.c
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/eclient.c
   * @brief Client code for event-driven programs
@@@ -92,6 -94,7 +92,7 @@@ typedef void operation_callback(disorde
  struct operation {
    struct operation *next;          /**< @brief next operation */
    char *cmd;                       /**< @brief command to send or 0 */
+   char **body;                     /**< @brief command body */
    operation_callback *opcallback;  /**< @brief internal completion callback */
    void (*completed)();             /**< @brief user completion callback or 0 */
    void *v;                         /**< @brief data for COMPLETED */
@@@ -165,6 -168,8 +166,8 @@@ static void stash_command(disorder_ecli
                            operation_callback *opcallback,
                            void (*completed)(),
                            void *v,
+                           int nbody,
+                           char **body,
                            const char *cmd,
                            ...);
  static void log_opcallback(disorder_eclient *c, struct operation *op);
@@@ -186,7 -191,9 +189,10 @@@ static void logentry_user_confirm(disor
  static void logentry_user_delete(disorder_eclient *c, int nvec, char **vec);
  static void logentry_user_edit(disorder_eclient *c, int nvec, char **vec);
  static void logentry_rights_changed(disorder_eclient *c, int nvec, char **vec);
 +static void logentry_adopted(disorder_eclient *c, int nvec, char **vec);
+ static void logentry_playlist_created(disorder_eclient *c, int nvec, char **vec);
+ static void logentry_playlist_deleted(disorder_eclient *c, int nvec, char **vec);
+ static void logentry_playlist_modified(disorder_eclient *c, int nvec, char **vec);
  
  /* Tables ********************************************************************/
  
@@@ -203,11 -210,13 +209,14 @@@ struct logentry_handler 
  /** @brief Table for parsing log entries */
  static const struct logentry_handler logentry_handlers[] = {
  #define LE(X, MIN, MAX) { #X, MIN, MAX, logentry_##X }
 +  LE(adopted, 2, 2),
    LE(completed, 1, 1),
    LE(failed, 2, 2),
    LE(moved, 1, 1),
    LE(playing, 1, 2),
+   LE(playlist_created, 2, 2),
+   LE(playlist_deleted, 1, 1),
+   LE(playlist_modified, 2, 2),
    LE(queue, 2, INT_MAX),
    LE(recent_added, 2, INT_MAX),
    LE(recent_removed, 1, 1),
@@@ -326,6 -335,24 +335,24 @@@ static int protocol_error(disorder_ecli
  
  /* State machine *************************************************************/
  
+ /** @brief Send an operation (into the output buffer)
+  * @param op Operation to send
+  */
+ static void op_send(struct operation *op) {
+   disorder_eclient *const c = op->client;
+   put(c, op->cmd, strlen(op->cmd));
+   if(op->body) {
+     for(int n = 0; op->body[n]; ++n) {
+       if(op->body[n][0] == '.')
+         put(c, ".", 1);
+       put(c, op->body[n], strlen(op->body[n]));
+       put(c, "\n", 1);
+     }
+     put(c, ".\n", 2);
+   }
+   op->sent = 1;
+ }
  /** @brief Called when there's something to do
   * @param c Client
   * @param mode bitmap of @ref DISORDER_POLL_READ and/or @ref DISORDER_POLL_WRITE.
@@@ -379,7 -406,7 +406,7 @@@ void disorder_eclient_polled(disorder_e
      D(("state_connected"));
      /* We just connected.  Initiate the authentication protocol. */
      stash_command(c, 1/*queuejump*/, authbanner_opcallback,
-                   0/*completed*/, 0/*v*/, 0/*cmd*/);
+                   0/*completed*/, 0/*v*/, -1/*nbody*/, 0/*body*/, 0/*cmd*/);
      /* We never stay is state_connected very long.  We could in principle jump
       * straight to state_cmdresponse since there's actually no command to
       * send, but that would arguably be cheating. */
        if(c->authenticated) {
          /* Transmit all unsent operations */
          for(op = c->ops; op; op = op->next) {
-           if(!op->sent) {
-             put(c, op->cmd, strlen(op->cmd));
-             op->sent = 1;
-           }
+           if(!op->sent)
+             op_send(op);
          }
        } else {
          /* Just send the head operation */
-         if(c->ops->cmd && !c->ops->sent) {
-           put(c, c->ops->cmd, strlen(c->ops->cmd));
-           c->ops->sent = 1;
-         }
+         if(c->ops->cmd && !c->ops->sent)
+           op_send(c->ops);
        }
        /* Awaiting response for the operation at the head of the list */
        c->state = state_cmdresponse;
@@@ -601,6 -624,7 +624,7 @@@ static void authbanner_opcallback(disor
      return;
    }
    stash_command(c, 1/*queuejump*/, authuser_opcallback, 0/*completed*/, 0/*v*/,
+                 -1/*nbody*/, 0/*body*/,
                  "user", quoteutf8(config->username), quoteutf8(res),
                  (char *)0);
  }
@@@ -625,6 -649,7 +649,7 @@@ static void authuser_opcallback(disorde
    if(c->log_callbacks && !(c->ops && c->ops->opcallback == log_opcallback))
      /* We are a log client, switch to logging mode */
      stash_command(c, 0/*queuejump*/, log_opcallback, 0/*completed*/, c->log_v,
+                   -1/*nbody*/, 0/*body*/,
                    "log", (char *)0);
  }
  
@@@ -787,6 -812,8 +812,8 @@@ static void stash_command_vector(disord
                                   operation_callback *opcallback,
                                   void (*completed)(),
                                   void *v,
+                                  int nbody,
+                                  char **body,
                                   int ncmd,
                                   char **cmd) {
    struct operation *op = xmalloc(sizeof *op);
      op->cmd = d.vec;
    } else
      op->cmd = 0;                        /* usually, awaiting challenge */
+   if(nbody >= 0) {
+     op->body = xcalloc(nbody + 1, sizeof (char *));
+     for(n = 0; n < nbody; ++n)
+       op->body[n] = xstrdup(body[n]);
+     op->body[n] = 0;
+   } else
+     op->body = NULL;
    op->opcallback = opcallback;
    op->completed = completed;
    op->v = v;
@@@ -830,6 -864,8 +864,8 @@@ static void vstash_command(disorder_ecl
                             operation_callback *opcallback,
                             void (*completed)(),
                             void *v,
+                            int nbody,
+                            char **body,
                             const char *cmd, va_list ap) {
    char *arg;
    struct vector vec;
      while((arg = va_arg(ap, char *)))
        vector_append(&vec, arg);
      stash_command_vector(c, queuejump, opcallback, completed, v, 
-                          vec.nvec, vec.vec);
+                          nbody, body, vec.nvec, vec.vec);
    } else
-     stash_command_vector(c, queuejump, opcallback, completed, v, 0, 0);
+     stash_command_vector(c, queuejump, opcallback, completed, v,
+                          nbody, body,
+                          0, 0);
  }
  
  static void stash_command(disorder_eclient *c,
                            operation_callback *opcallback,
                            void (*completed)(),
                            void *v,
+                           int nbody,
+                           char **body,
                            const char *cmd,
                            ...) {
    va_list ap;
  
    va_start(ap, cmd);
-   vstash_command(c, queuejump, opcallback, completed, v, cmd, ap);
+   vstash_command(c, queuejump, opcallback, completed, v, nbody, body, cmd, ap);
    va_end(ap);
  }
  
@@@ -1008,6 -1048,8 +1048,8 @@@ static void list_response_opcallback(di
    D(("list_response_callback"));
    if(c->rc / 100 == 2)
      completed(op->v, NULL, c->vec.nvec, c->vec.vec);
+   else if(c->rc == 555)
+     completed(op->v, NULL, -1, NULL);
    else
      completed(op->v, errorstring(c), 0, 0);
  }
@@@ -1039,7 -1081,24 +1081,24 @@@ static int simple(disorder_eclient *c
    va_list ap;
  
    va_start(ap, cmd);
-   vstash_command(c, 0/*queuejump*/, opcallback, completed, v, cmd, ap);
+   vstash_command(c, 0/*queuejump*/, opcallback, completed, v, -1, 0, cmd, ap);
+   va_end(ap);
+   /* Give the state machine a kick, since we might be in state_idle */
+   disorder_eclient_polled(c, 0);
+   return 0;
+ }
+ static int simple_body(disorder_eclient *c,
+                        operation_callback *opcallback,
+                        void (*completed)(),
+                        void *v,
+                        int nbody,
+                        char **body,
+                        const char *cmd, ...) {
+   va_list ap;
+   va_start(ap, cmd);
+   vstash_command(c, 0/*queuejump*/, opcallback, completed, v, nbody, body, cmd, ap);
    va_end(ap);
    /* Give the state machine a kick, since we might be in state_idle */
    disorder_eclient_polled(c, 0);
@@@ -1124,7 -1183,7 +1183,7 @@@ int disorder_eclient_moveafter(disorder
    for(n = 0; n < nids; ++n)
      vector_append(&vec, (char *)ids[n]);
    stash_command_vector(c, 0/*queuejump*/, no_response_opcallback, completed, v,
-                        vec.nvec, vec.vec);
+                        -1, 0, vec.nvec, vec.vec);
    disorder_eclient_polled(c, 0);
    return 0;
  }
@@@ -1406,20 -1465,123 +1465,137 @@@ int disorder_eclient_adduser(disorder_e
                  "adduser", user, password, rights, (char *)0);
  }
  
 +/** @brief Adopt a track
 + * @param c Client
 + * @param completed Called on completion
 + * @param id Track ID
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_adopt(disorder_eclient *c,
 +                           disorder_eclient_no_response *completed,
 +                           const char *id,
 +                           void *v) {
 +  return simple(c, no_response_opcallback, (void (*)())completed, v, 
 +                "adopt", id, (char *)0);
 +}
 +
+ /** @brief Get the list of playlists
+  * @param c Client
+  * @param completed Called with list of playlists
+  * @param v Passed to @p completed
+  *
+  * The playlist list is not sorted in any particular order.
+  */
+ int disorder_eclient_playlists(disorder_eclient *c,
+                                disorder_eclient_list_response *completed,
+                                void *v) {
+   return simple(c, list_response_opcallback, (void (*)())completed, v,
+                 "playlists", (char *)0);
+ }
+ /** @brief Delete a playlist
+  * @param c Client
+  * @param completed Called on completion
+  * @param playlist Playlist to delete
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_delete(disorder_eclient *c,
+                                      disorder_eclient_no_response *completed,
+                                      const char *playlist,
+                                      void *v) {
+   return simple(c, no_response_opcallback,  (void (*)())completed, v,
+                 "playlist-delete", playlist, (char *)0);
+ }
+ /** @brief Lock a playlist
+  * @param c Client
+  * @param completed Called on completion
+  * @param playlist Playlist to lock
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_lock(disorder_eclient *c,
+                                    disorder_eclient_no_response *completed,
+                                    const char *playlist,
+                                    void *v) {
+   return simple(c, no_response_opcallback,  (void (*)())completed, v,
+                 "playlist-lock", playlist, (char *)0);
+ }
+ /** @brief Unlock the locked a playlist
+  * @param c Client
+  * @param completed Called on completion
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_unlock(disorder_eclient *c,
+                                      disorder_eclient_no_response *completed,
+                                      void *v) {
+   return simple(c, no_response_opcallback,  (void (*)())completed, v,
+                 "playlist-unlock", (char *)0);
+ }
+ /** @brief Set a playlist's sharing
+  * @param c Client
+  * @param completed Called on completion
+  * @param playlist Playlist to modify
+  * @param sharing @c "public" or @c "private"
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_set_share(disorder_eclient *c,
+                                         disorder_eclient_no_response *completed,
+                                         const char *playlist,
+                                         const char *sharing,
+                                         void *v) {
+   return simple(c, no_response_opcallback,  (void (*)())completed, v,
+                 "playlist-set-share", playlist, sharing, (char *)0);
+ }
+ /** @brief Get a playlist's sharing
+  * @param c Client
+  * @param completed Called with sharing status
+  * @param playlist Playlist to inspect
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_get_share(disorder_eclient *c,
+                                         disorder_eclient_string_response *completed,
+                                         const char *playlist,
+                                         void *v) {
+   return simple(c, string_response_opcallback,  (void (*)())completed, v,
+                 "playlist-get-share", playlist, (char *)0);
+ }
+ /** @brief Set a playlist
+  * @param c Client
+  * @param completed Called on completion
+  * @param playlist Playlist to modify
+  * @param tracks List of tracks
+  * @param ntracks Number of tracks
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_set(disorder_eclient *c,
+                                   disorder_eclient_no_response *completed,
+                                   const char *playlist,
+                                   char **tracks,
+                                   int ntracks,
+                                   void *v) {
+   return simple_body(c, no_response_opcallback, (void (*)())completed, v,
+                      ntracks, tracks,
+                      "playlist-set", playlist, (char *)0);
+ }
+ /** @brief Get a playlist's contents
+  * @param c Client
+  * @param completed Called with playlist contents
+  * @param playlist Playlist to inspect
+  * @param v Passed to @p completed
+  */
+ int disorder_eclient_playlist_get(disorder_eclient *c,
+                                   disorder_eclient_list_response *completed,
+                                   const char *playlist,
+                                   void *v) {
+   return simple(c, list_response_opcallback,  (void (*)())completed, v,
+                 "playlist-get", playlist, (char *)0);
+ }
  /* Log clients ***************************************************************/
  
  /** @brief Monitor the server log
@@@ -1444,7 -1606,7 +1620,7 @@@ int disorder_eclient_log(disorder_eclie
    if(c->log_callbacks->state)
      c->log_callbacks->state(c->log_v, c->statebits);
    stash_command(c, 0/*queuejump*/, log_opcallback, 0/*completed*/, v,
-                 "log", (char *)0);
+                 -1, 0, "log", (char *)0);
    disorder_eclient_polled(c, 0);
    return 0;
  }
@@@ -1612,6 -1774,27 +1788,27 @@@ static void logentry_rights_changed(dis
    }
  }
  
+ static void logentry_playlist_created(disorder_eclient *c,
+                                       int attribute((unused)) nvec,
+                                       char **vec) {
+   if(c->log_callbacks->playlist_created)
+     c->log_callbacks->playlist_created(c->log_v, vec[0], vec[1]);
+ }
+ static void logentry_playlist_deleted(disorder_eclient *c,
+                                       int attribute((unused)) nvec,
+                                       char **vec) {
+   if(c->log_callbacks->playlist_deleted)
+     c->log_callbacks->playlist_deleted(c->log_v, vec[0]);
+ }
+ static void logentry_playlist_modified(disorder_eclient *c,
+                                       int attribute((unused)) nvec,
+                                       char **vec) {
+   if(c->log_callbacks->playlist_modified)
+     c->log_callbacks->playlist_modified(c->log_v, vec[0], vec[1]);
+ }
  static const struct {
    unsigned long bit;
    const char *enable;
@@@ -1694,12 -1877,6 +1891,12 @@@ char *disorder_eclient_interpret_state(
    return d->vec;
  }
  
 +static void logentry_adopted(disorder_eclient *c,
 +                             int attribute((unused)) nvec, char **vec) {
 +  if(c->log_callbacks->adopted) 
 +    c->log_callbacks->adopted(c->log_v, vec[0], vec[1]);
 +}
 +
  /*
  Local Variables:
  c-basic-offset:2
diff --combined lib/eclient.h
@@@ -1,19 -1,21 +1,19 @@@
  /*
   * This file is part of DisOrder.
-  * Copyright (C) 2006, 2007 Richard Kettlewell
+  * Copyright (C) 2006-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/eclient.h
   * @brief Client code for event-driven programs
@@@ -125,7 -127,7 +125,7 @@@ typedef struct disorder_eclient_log_cal
    /** @brief Called when @p id is removed from the recent list */
    void (*recent_removed)(void *v, const char *id);
  
 -  /** @brief Called when @id is removed from the queue
 +  /** @brief Called when @id is removed from the queue
     *
     * @p user might be 0.
     */
    /** @brief Called when your rights change */
    void (*rights_changed)(void *v, rights_type new_rights);
  
 +  /** @brief Called when a track is adopted */
 +  void (*adopted)(void *v, const char *id, const char *who);
++
+   /** @brief Called when a new playlist is created */
+   void (*playlist_created)(void *v, const char *playlist, const char *sharing);
+   /** @brief Called when a playlist is modified */
+   void (*playlist_modified)(void *v, const char *playlist, const char *sharing);
+   /** @brief Called when a new playlist is deleted */
+   void (*playlist_deleted)(void *v, const char *playlist);
  } disorder_eclient_log_callbacks;
  
  /* State bits */
@@@ -222,7 -230,8 +231,8 @@@ typedef void disorder_eclient_no_respon
   *
   * @p error will be NULL on success.  In this case @p value will be the result
   * (which might be NULL for disorder_eclient_get(),
-  * disorder_eclient_get_global() and disorder_eclient_userinfo()).
+  * disorder_eclient_get_global(), disorder_eclient_userinfo() and
+  * disorder_eclient_playlist_get_share()).
   *
   * @p error will be non-NULL on failure.  In this case @p value is always NULL.
   */
@@@ -281,7 -290,8 +291,8 @@@ typedef void disorder_eclient_queue_res
   * @param vec Pointer to response list
   *
   * @p error will be NULL on success.  In this case @p nvec and @p vec will give
-  * the result.
+  * the result, or be -1 and NULL respectively e.g. from
+  * disorder_eclient_playlist_get() if there is no such playlist.
   *
   * @p error will be non-NULL on failure.  In this case @p nvec and @p vec will
   * be 0 and NULL.
@@@ -486,10 -496,40 +497,44 @@@ int disorder_eclient_adduser(disorder_e
                               void *v);
  void disorder_eclient_enable_connect(disorder_eclient *c);
  void disorder_eclient_disable_connect(disorder_eclient *c);
 +int disorder_eclient_adopt(disorder_eclient *c,
 +                           disorder_eclient_no_response *completed,
 +                           const char *id,
 +                           void *v);  
+ int disorder_eclient_playlists(disorder_eclient *c,
+                                disorder_eclient_list_response *completed,
+                                void *v);
+ int disorder_eclient_playlist_delete(disorder_eclient *c,
+                                      disorder_eclient_no_response *completed,
+                                      const char *playlist,
+                                      void *v);
+ int disorder_eclient_playlist_lock(disorder_eclient *c,
+                                    disorder_eclient_no_response *completed,
+                                    const char *playlist,
+                                    void *v);
+ int disorder_eclient_playlist_unlock(disorder_eclient *c,
+                                      disorder_eclient_no_response *completed,
+                                      void *v);
+ int disorder_eclient_playlist_set_share(disorder_eclient *c,
+                                         disorder_eclient_no_response *completed,
+                                         const char *playlist,
+                                         const char *sharing,
+                                         void *v);
+ int disorder_eclient_playlist_get_share(disorder_eclient *c,
+                                         disorder_eclient_string_response *completed,
+                                         const char *playlist,
+                                         void *v);
+ int disorder_eclient_playlist_set(disorder_eclient *c,
+                                   disorder_eclient_no_response *completed,
+                                   const char *playlist,
+                                   char **tracks,
+                                   int ntracks,
+                                   void *v);
+ int disorder_eclient_playlist_get(disorder_eclient *c,
+                                   disorder_eclient_list_response *completed,
+                                   const char *playlist,
+                                   void *v);
  #endif
  
  /*
diff --combined lib/trackdb-int.h
@@@ -2,26 -2,28 +2,27 @@@
   * This file is part of DisOrder
   * Copyright (C) 2005, 2007 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
 -
 +/** @file lib/trackdb-int.h
 + * @brief Track database internals */
  #ifndef TRACKDB_INT_H
  #define TRACKDB_INT_H
  
  #include <db.h>
  
+ #include "trackdb.h"
  #include "kvp.h"
  
  struct vector;                          /* forward declaration */
@@@ -36,6 -38,7 +37,7 @@@ extern DB *trackdb_noticeddb
  extern DB *trackdb_globaldb;
  extern DB *trackdb_usersdb;
  extern DB *trackdb_scheduledb;
+ extern DB *trackdb_playlistsdb;
  
  DBC *trackdb_opencursor(DB *db, DB_TXN *tid);
  /* open a transaction */
@@@ -151,6 -154,7 +153,7 @@@ int trackdb_get_global_tid(const char *
  
  char **parsetags(const char *s);
  int tag_intersection(char **a, char **b);
+ int valid_username(const char *user);
  
  #endif /* TRACKDB_INT_H */
  
diff --combined lib/trackdb.c
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder
   * Copyright (C) 2005-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/trackdb.c
   * @brief Track database
@@@ -157,6 -159,13 +157,13 @@@ DB *trackdb_scheduledb
   */
  DB *trackdb_usersdb;
  
+ /** @brief The playlists database
+  * - Keys are playlist names
+  * - Values are encoded key-value pairs
+  * - Data is user data and cannot be reconstructed
+  */
+ DB *trackdb_playlistsdb;
  static pid_t db_deadlock_pid = -1;      /* deadlock manager PID */
  static pid_t rescan_pid = -1;           /* rescanner PID */
  static int initialized, opened;         /* state */
@@@ -354,7 -363,7 +361,7 @@@ static DB *open_db(const char *path
                     DBTYPE dbtype,
                     u_int32_t openflags,
                     int mode) {
 -  int err;
 +  int err, err2;
    DB *db;
  
    D(("open %s", path));
        fatal(0, "db->set_bt_compare %s: %s", path, db_strerror(err));
    if((err = db->open(db, 0, path, 0, dbtype,
                       openflags | DB_AUTO_COMMIT, mode))) {
 -    if((openflags & DB_CREATE) || errno != ENOENT)
 +    if((openflags & DB_CREATE) || errno != ENOENT) {
 +      if((err2 = db->close(db, 0)))
 +        error(0, "db->close: %s", db_strerror(err2));
 +      trackdb_close();
 +      trackdb_env->close(trackdb_env,0);
 +      trackdb_env = 0;
        fatal(0, "db->open %s: %s", path, db_strerror(err));
 +    }
      db->close(db, 0);
      db = 0;
    }
@@@ -472,6 -475,7 +479,7 @@@ void trackdb_open(int flags) 
    trackdb_noticeddb = open_db("noticed.db",
                               DB_DUPSORT, DB_BTREE, dbflags, 0666);
    trackdb_scheduledb = open_db("schedule.db", 0, DB_HASH, dbflags, 0666);
+   trackdb_playlistsdb = open_db("playlists.db", 0, DB_HASH, dbflags, 0666);
    if(!trackdb_existing_database) {
      /* Stash the database version */
      char buf[32];
@@@ -490,19 -494,26 +498,20 @@@ void trackdb_close(void) 
    /* sanity checks */
    assert(opened == 1);
    --opened;
 -  if((err = trackdb_tracksdb->close(trackdb_tracksdb, 0)))
 -    fatal(0, "error closing tracks.db: %s", db_strerror(err));
 -  if((err = trackdb_searchdb->close(trackdb_searchdb, 0)))
 -    fatal(0, "error closing search.db: %s", db_strerror(err));
 -  if((err = trackdb_tagsdb->close(trackdb_tagsdb, 0)))
 -    fatal(0, "error closing tags.db: %s", db_strerror(err));
 -  if((err = trackdb_prefsdb->close(trackdb_prefsdb, 0)))
 -    fatal(0, "error closing prefs.db: %s", db_strerror(err));
 -  if((err = trackdb_globaldb->close(trackdb_globaldb, 0)))
 -    fatal(0, "error closing global.db: %s", db_strerror(err));
 -  if((err = trackdb_noticeddb->close(trackdb_noticeddb, 0)))
 -    fatal(0, "error closing noticed.db: %s", db_strerror(err));
 -  if((err = trackdb_scheduledb->close(trackdb_scheduledb, 0)))
 -    fatal(0, "error closing schedule.db: %s", db_strerror(err));
 -  if((err = trackdb_usersdb->close(trackdb_usersdb, 0)))
 -    fatal(0, "error closing users.db: %s", db_strerror(err));
 -  if((err = trackdb_playlistsdb->close(trackdb_playlistsdb, 0)))
 -    fatal(0, "error closing playlists.db: %s", db_strerror(err));
 -  trackdb_tracksdb = trackdb_searchdb = trackdb_prefsdb = 0;
 -  trackdb_tagsdb = trackdb_globaldb = 0;
 +#define CLOSE(N, V) do {                                        \
 +  if(V && (err = V->close(V, 0)))                               \
 +    fatal(0, "error closing %s: %s", N, db_strerror(err));      \
 +  V = 0;                                                        \
 +} while(0)
 +  CLOSE("tracks.db", trackdb_tracksdb);
 +  CLOSE("search.db", trackdb_searchdb);
 +  CLOSE("tags.db", trackdb_tagsdb);
 +  CLOSE("prefs.db", trackdb_prefsdb);
 +  CLOSE("global.db", trackdb_globaldb);
 +  CLOSE("noticed.db", trackdb_noticeddb);
 +  CLOSE("schedule.db", trackdb_scheduledb);
 +  CLOSE("users.db", trackdb_usersdb);
++  CLOSE("playlists.db", trackdb_playlistsdb);
    D(("closed databases"));
  }
  
@@@ -1400,9 -1411,7 +1409,9 @@@ void trackdb_stats_subprocess(ev_sourc
    pid = subprogram(ev, p[1], "disorder-stats", (char *)0);
    xclose(p[1]);
    ev_child(ev, pid, 0, stats_finished, d);
 -  ev_reader_new(ev, p[0], stats_read, stats_error, d, "disorder-stats reader");
 +  if(!ev_reader_new(ev, p[0], stats_read, stats_error, d,
 +                    "disorder-stats reader"))
 +    fatal(0, "ev_reader_new for disorder-stats reader failed");
  }
  
  /** @brief Parse a track name part preference
@@@ -1758,9 -1767,8 +1767,9 @@@ int trackdb_request_random(ev_source *e
    choose_callback = callback;
    choose_output.nvec = 0;
    choose_complete = 0;
 -  ev_reader_new(ev, p[0], choose_readable, choose_read_error, 0,
 -                "disorder-choose reader"); /* owns p[0] */
 +  if(!ev_reader_new(ev, p[0], choose_readable, choose_read_error, 0,
 +                    "disorder-choose reader")) /* owns p[0] */
 +    fatal(0, "ev_reader_new for disorder-choose reader failed");
    ev_child(ev, choose_pid, 0, choose_exited, 0); /* owns the subprocess */
    return 0;
  }
@@@ -2250,7 -2258,7 +2259,7 @@@ static int reap_rescan(ev_source attrib
   * @param ev Event loop or 0 to block
   * @param recheck 1 to recheck lengths, 0 to suppress check
   * @param rescanned Called on completion (if not NULL)
 - * @param u Passed to @p rescanned
 + * @param ru Passed to @p rescanned
   */
  void trackdb_rescan(ev_source *ev, int recheck,
                      void (*rescanned)(void *ru),
@@@ -2553,8 -2561,10 +2562,10 @@@ static int trusted(const char *user) 
   * Currently we only allow the letters and digits in ASCII.  We could be more
   * liberal than this but it is a nice simple test.  It is critical that
   * semicolons are never allowed.
+  *
+  * NB also used by playlist_parse_name() to validate playlist names!
   */
static int valid_username(const char *user) {
+ int valid_username(const char *user) {
    if(!*user)
      return 0;
    while(*user) {
diff --combined lib/trackdb.h
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder
   * Copyright (C) 2005-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
 - *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * 
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  /** @file lib/trackdb.h
   * @brief Track database public interface */
@@@ -184,6 -186,25 +184,25 @@@ void trackdb_add_rescanned(void (*resca
                             void *ru);
  int trackdb_rescan_underway(void);
  
+ int playlist_parse_name(const char *name,
+                         char **ownerp,
+                         char **sharep);
+ int trackdb_playlist_get(const char *name,
+                          const char *who,
+                          char ***tracksp,
+                          int *ntracksp,
+                          char **sharep);
+ int trackdb_playlist_set(const char *name,
+                          const char *who,
+                          char **tracks,
+                          int ntracks,
+                          const char *share);
+ void trackdb_playlist_list(const char *who,
+                            char ***playlistsp,
+                            int *nplaylistsp);
+ int trackdb_playlist_delete(const char *name,
+                             const char *who);
  #endif /* TRACKDB_H */
  
  /*
diff --combined python/disorder.py.in
@@@ -1,18 -1,20 +1,18 @@@
  #
  # Copyright (C) 2004, 2005, 2007, 2008 Richard Kettlewell
  #
 -# This program is free software; you can redistribute it and/or modify
 +# This program is free software: you can redistribute it and/or modify
  # it under the terms of the GNU General Public License as published by
 -# the Free Software Foundation; either version 2 of the License, or
 +# the Free Software Foundation, either version 3 of the License, or
  # (at your option) any later version.
  #
 -# This program is distributed in the hope that it will be useful, but
 -# WITHOUT ANY WARRANTY; without even the implied warranty of
 -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -# General Public License for more details.
 -#
 +# This program is distributed in the hope that it will be useful,
 +# but WITHOUT ANY WARRANTY; without even the implied warranty of
 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +# GNU General Public License for more details.
 +# 
  # You should have received a copy of the GNU General Public License
 -# along with this program; if not, write to the Free Software
 -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -# USA
 +# along with this program.  If not, see <http://www.gnu.org/licenses/>.
  #
  
  """Python support for DisOrder
@@@ -113,8 -115,8 +113,8 @@@ class operationError(Error)
      self.cmd_ = cmd
      self.details_ = details
    def __str__(self):
-     """Return the complete response string from the server, with the command
-     if available.
+     """Return the complete response string from the server, with the
+     command if available.
  
      Excludes the final newline.
      """
@@@ -422,8 -424,8 +422,8 @@@ class client
  
      Returns the ID of the new queue entry.
  
-     Note that queue IDs are unicode strings (because all track information
-     values are unicode strings).
+     Note that queue IDs are unicode strings (because all track
+     information values are unicode strings).
      """
      res, details = self._simple("play", track)
      return unicode(details)             # because it's unicode in queue() output
      The return value is a list of dictionaries corresponding to
      recently played tracks.  The next track to be played comes first.
  
-     See disorder_protocol(5) for the meanings of the keys.  All keys are
-     plain strings but the values will be unicode strings."""
+     See disorder_protocol(5) for the meanings of the keys.
+     All keys are plain strings but the values will be unicode strings."""
      return self._somequeue("queue")
  
    def _somedir(self, command, dir, re):
      
      The callback should return True to continue or False to stop (don't
      forget this, or your program will mysteriously misbehave).  Once you
-     stop reading the log the connection is useless and should be deleted.
+     stop reading the log the connection is useless and should be
+     deleted.
  
      It is suggested that you use the disorder.monitor class instead of
      calling this method directly, but this is not mandatory.
      self._simple("schedule-del", event)
  
    def schedule_get(self, event):
-     """Get the details for an event as a dict (returns None if event not found)"""
+     """Get the details for an event as a dict (returns None if
+     event not found)"""
      res, details = self._simple("schedule-get", event)
      if res == 555:
        return None
      """Add a scheduled event"""
      self._simple("schedule-add", str(when), priority, action, *rest)
  
 +  def adopt(self, id):
 +    """Adopt a randomly picked track"""
 +    self._simple("adopt", id)
 +
+   def playlist_delete(self, playlist):
+     """Delete a playlist"""
+     res, details = self._simple("playlist-delete", playlist)
+     if res == 555:
+       raise operationError(res, details, "playlist-delete")
+   def playlist_get(self, playlist):
+     """Get the contents of a playlist
+     The return value is an array of track names, or None if there is no
+     such playlist."""
+     res, details = self._simple("playlist-get", playlist)
+     if res == 555:
+       return None
+     return self._body()
+   def playlist_lock(self, playlist):
+     """Lock a playlist.  Playlists can only be modified when locked."""
+     self._simple("playlist-lock", playlist)
+   def playlist_unlock(self):
+     """Unlock the locked playlist."""
+     self._simple("playlist-unlock")
+   def playlist_set(self, playlist, tracks):
+     """Set the contents of a playlist.  The playlist must be locked.
+     Arguments:
+     playlist -- Playlist to set
+     tracks -- Array of tracks"""
+     self._simple_body(tracks, "playlist-set", playlist)
+   def playlist_set_share(self, playlist, share):
+     """Set the sharing status of a playlist"""
+     self._simple("playlist-set-share", playlist, share)
+   def playlist_get_share(self, playlist):
+     """Returns the sharing status of a playlist"""
+     res, details = self._simple("playlist-get-share", playlist)
+     if res == 555:
+       return None
+     return _split(details)[0]
+   def playlists(self):
+     """Returns the list of visible playlists"""
+     self._simple("playlists")
+     return self._body()
    ########################################################################
    # I/O infrastructure
  
      else:
        raise protocolError(self.who, "invalid response %s")
  
-   def _send(self, *command):
-     # Quote and send a command
+   def _send(self, body, *command):
+     # Quote and send a command and optional body
      #
      # Returns the encoded command.
      quoted = _quote(command)
      try:
        self.w.write(encoded)
        self.w.write("\n")
+       if body != None:
+         for l in body:
+           if l[0] == ".":
+             self.w.write(".")
+           self.w.write(l)
+           self.w.write("\n")
+         self.w.write(".\n")
        self.w.flush()
        return encoded
      except IOError, e:
        self._disconnect()
        raise
  
-   def _simple(self, *command):
+   def _simple(self, *command): 
      # Issue a simple command, throw an exception on error
      #
      # If an I/O error occurs, disconnect from the server.
      # On success or 'normal' errors returns response as a (code, details) tuple
      #
      # On error raise operationError
+     return self._simple_body(None, *command)
+  
+   def _simple_body(self, body, *command):
+     # Issue a simple command with optional body, throw an exception on error
+     #
+     # If an I/O error occurs, disconnect from the server.
+     #
+     # On success or 'normal' errors returns response as a (code, details) tuple
+     #
+     # On error raise operationError
      if self.state == 'disconnected':
        self.connect()
      if command:
-       cmd = self._send(*command)
+       cmd = self._send(body, *command)
      else:
        cmd = None
      res, details = self._response()
  class monitor:
    """DisOrder event log monitor class
  
-   Intended to be subclassed with methods corresponding to event log messages
-   the implementor cares about over-ridden."""
+   Intended to be subclassed with methods corresponding to event log
+   messages the implementor cares about over-ridden."""
  
    def __init__(self, c=None):
      """Constructor for the monitor class
  
    def run(self):
      """Start monitoring logs.  Continues monitoring until one of the
-     message-specific methods returns False.  Can be called more than once
-     (but not recursively!)"""
+     message-specific methods returns False.  Can be called more than
+     once (but not recursively!)"""
      self.c.log(self._callback)
  
    def when(self):
diff --combined scripts/completion.bash
@@@ -2,18 -2,20 +2,18 @@@
  # This file is part of DisOrder.
  # Copyright (C) 2005-2008 Richard Kettlewell
  #
 -# This program is free software; you can redistribute it and/or modify
 +# This program is free software: you can redistribute it and/or modify
  # it under the terms of the GNU General Public License as published by
 -# the Free Software Foundation; either version 2 of the License, or
 +# the Free Software Foundation, either version 3 of the License, or
  # (at your option) any later version.
 -#
 -# This program is distributed in the hope that it will be useful, but
 -# WITHOUT ANY WARRANTY; without even the implied warranty of
 -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -# General Public License for more details.
 -#
 +# 
 +# This program is distributed in the hope that it will be useful,
 +# but WITHOUT ANY WARRANTY; without even the implied warranty of
 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +# GNU General Public License for more details.
 +# 
  # You should have received a copy of the GNU General Public License
 -# along with this program; if not, write to the Free Software
 -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -# USA
 +# along with this program.  If not, see <http://www.gnu.org/licenses/>.
  #
  
  complete -r disorder 2>/dev/null || true
@@@ -32,7 -34,7 +32,8 @@@ complete -o default 
               tags new rtp-address adduser users edituser deluser userinfo
               setup-guest schedule-del schedule-list
               schedule-set-global schedule-unset-global schedule-play
 +             adopt
+              playlist-del playlist-get playlist-set playlists
               -h --help -H --help-commands --version -V --config -c
               --length --debug -d" \
         disorder
diff --combined server/dump.c
@@@ -2,22 -2,22 +2,22 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2004, 2005, 2007, 2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +/** @file server/dump.c
 + * @brief Dump and restore database contents
   */
 -
  #include "disorder-server.h"
  
  static const struct option options[] = {
@@@ -29,8 -29,6 +29,6 @@@
    { "debug", no_argument, 0, 'D' },
    { "recover", no_argument, 0, 'r' },
    { "recover-fatal", no_argument, 0, 'R' },
-   { "trackdb", no_argument, 0, 't' },
-   { "searchdb", no_argument, 0, 's' },
    { "recompute-aliases", no_argument, 0, 'a' },
    { "remove-pathless", no_argument, 0, 'P' },
    { 0, 0, 0, 0 }
@@@ -55,14 -53,70 +53,70 @@@ static void help(void) 
    exit(0);
  }
  
+ /** @brief Dump one record
+  * @param s Output stream
+  * @param tag Tag for error messages
+  * @param letter Prefix leter for dumped record
+  * @param dbname Database name
+  * @param db Database handle
+  * @param tid Transaction handle
+  * @return 0 or @c DB_LOCK_DEADLOCK
+  */
+ static int dump_one(struct sink *s,
+                     const char *tag,
+                     int letter,
+                     const char *dbname,
+                     DB *db,
+                     DB_TXN *tid) {
+   int err;
+   DBC *cursor;
+   DBT k, d;
+   /* dump the preferences */
+   cursor = trackdb_opencursor(db, tid);
+   err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
+                       DB_FIRST);
+   while(err == 0) {
+     if(sink_writec(s, letter) < 0
+        || urlencode(s, k.data, k.size)
+        || sink_writec(s, '\n') < 0
+        || urlencode(s, d.data, d.size)
+        || sink_writec(s, '\n') < 0)
+       fatal(errno, "error writing to %s", tag);
+     err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
+                         DB_NEXT);
+   }
+   switch(err) {
+   case DB_LOCK_DEADLOCK:
+     trackdb_closecursor(cursor);
+     return err;
+   case DB_NOTFOUND:
+     return trackdb_closecursor(cursor);
+   case 0:
+     assert(!"cannot happen");
+   default:
+     fatal(0, "error reading %s: %s", dbname, db_strerror(err));
+   }
+ }
+ static struct {
+   int letter;
+   const char *dbname;
+   DB **db;
+ } dbtable[] = {
+   { 'P', "prefs.db",     &trackdb_prefsdb },
+   { 'G', "global.db",    &trackdb_globaldb },
+   { 'U', "users.db",     &trackdb_usersdb },
+   { 'W', "schedule.db",  &trackdb_scheduledb },
+   { 'L', "playlists.db", &trackdb_playlistsdb },
+   /* avoid 'T' and 'S' for now */
+ };
+ #define NDBTABLE (sizeof dbtable / sizeof *dbtable)
  /* dump prefs to FP, return nonzero on error */
- static void do_dump(FILE *fp, const char *tag,
-                   int tracksdb, int searchdb) {
-   DBC *cursor = 0;
+ static void do_dump(FILE *fp, const char *tag) {
    DB_TXN *tid;
    struct sink *s = sink_stdio(tag, fp);
-   int err;
-   DBT k, d;
  
    for(;;) {
      tid = trackdb_begin_transaction();
        fatal(errno, "error calling fflush");
      if(ftruncate(fileno(fp), 0) < 0)
        fatal(errno, "error calling ftruncate");
-     if(fprintf(fp, "V%c\n", (tracksdb || searchdb) ? '1' : '0') < 0)
+     if(fprintf(fp, "V0") < 0)
        fatal(errno, "error writing to %s", tag);
-     /* dump the preferences */
-     cursor = trackdb_opencursor(trackdb_prefsdb, tid);
-     err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                         DB_FIRST);
-     while(err == 0) {
-       if(fputc('P', fp) < 0
-          || urlencode(s, k.data, k.size)
-          || fputc('\n', fp) < 0
-          || urlencode(s, d.data, d.size)
-          || fputc('\n', fp) < 0)
-         fatal(errno, "error writing to %s", tag);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                           DB_NEXT);
-     }
-     if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }
-     cursor = 0;
-     /* dump the global preferences */
-     cursor = trackdb_opencursor(trackdb_globaldb, tid);
-     err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                         DB_FIRST);
-     while(err == 0) {
-       if(fputc('G', fp) < 0
-          || urlencode(s, k.data, k.size)
-          || fputc('\n', fp) < 0
-          || urlencode(s, d.data, d.size)
-          || fputc('\n', fp) < 0)
-         fatal(errno, "error writing to %s", tag);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                           DB_NEXT);
-     }
-     if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }
-     cursor = 0;
+     for(size_t n = 0; n < NDBTABLE; ++n)
+       if(dump_one(s, tag,
+                   dbtable[n].letter, dbtable[n].dbname, *dbtable[n].db,
+                   tid))
+         goto fail;
      
-     /* dump the users */
-     cursor = trackdb_opencursor(trackdb_usersdb, tid);
-     err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                         DB_FIRST);
-     while(err == 0) {
-       if(fputc('U', fp) < 0
-          || urlencode(s, k.data, k.size)
-          || fputc('\n', fp) < 0
-          || urlencode(s, d.data, d.size)
-          || fputc('\n', fp) < 0)
-         fatal(errno, "error writing to %s", tag);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                           DB_NEXT);
-     }
-     if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }
-     cursor = 0;
-     /* dump the schedule */
-     cursor = trackdb_opencursor(trackdb_scheduledb, tid);
-     err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                         DB_FIRST);
-     while(err == 0) {
-       if(fputc('W', fp) < 0
-          || urlencode(s, k.data, k.size)
-          || fputc('\n', fp) < 0
-          || urlencode(s, d.data, d.size)
-          || fputc('\n', fp) < 0)
-         fatal(errno, "error writing to %s", tag);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                           DB_NEXT);
-     }
-     if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }
-     cursor = 0;
-     
-     
-     if(tracksdb) {
-       cursor = trackdb_opencursor(trackdb_tracksdb, tid);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                         DB_FIRST);
-       while(err == 0) {
-       if(fputc('T', fp) < 0
-          || urlencode(s, k.data, k.size)
-          || fputc('\n', fp) < 0
-          || urlencode(s, d.data, d.size)
-          || fputc('\n', fp) < 0)
-         fatal(errno, "error writing to %s", tag);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                           DB_NEXT);
-       }
-       if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }
-       cursor = 0;
-     }
-     if(searchdb) {
-       cursor = trackdb_opencursor(trackdb_searchdb, tid);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                         DB_FIRST);
-       while(err == 0) {
-       if(fputc('S', fp) < 0
-          || urlencode(s, k.data, k.size)
-          || fputc('\n', fp) < 0
-          || urlencode(s, d.data, d.size)
-          || fputc('\n', fp) < 0)
-         fatal(errno, "error writing to %s", tag);
-       err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d),
-                           DB_NEXT);
-       }
-       if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }      cursor = 0;
-     }
-     if(fputs("E\n", fp) < 0) fatal(errno, "error writing to %s", tag);
-     if(err == DB_LOCK_DEADLOCK) {
-       error(0, "c->c_get: %s", db_strerror(err));
-       goto fail;
-     }
-     if(err && err != DB_NOTFOUND)
-       fatal(0, "cursor->c_get: %s", db_strerror(err));
-     if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; }
+     if(fputs("E\n", fp) < 0)
+       fatal(errno, "error writing to %s", tag);
      break;
  fail:
-     trackdb_closecursor(cursor);
-     cursor = 0;
      info("aborting transaction and retrying dump");
      trackdb_abort_transaction(tid);
    }
@@@ -276,9 -224,6 +224,6 @@@ static int undump_dbt(FILE *fp, const c
  /* undump from FP, return 0 or DB_LOCK_DEADLOCK */
  static int undump_from_fp(DB_TXN *tid, FILE *fp, const char *tag) {
    int err, c;
-   DBT k, d;
-   const char *which_name;
-   DB *which_db;
  
    info("undumping");
    if(fseek(fp, 0, SEEK_SET) < 0)
    if((err = truncdb(tid, trackdb_scheduledb))) return err;
    c = getc(fp);
    while(!ferror(fp) && !feof(fp)) {
+     for(size_t n = 0; n < NDBTABLE; ++n) {
+       if(dbtable[n].letter == c) {
+       DB *db = *dbtable[n].db;
+       const char *dbname = dbtable[n].dbname;
+         DBT k, d;
+         if(undump_dbt(fp, tag, prepare_data(&k))
+            || undump_dbt(fp, tag, prepare_data(&d)))
+           break;
+         switch(err = db->put(db, tid, &k, &d, 0)) {
+         case 0:
+           break;
+         case DB_LOCK_DEADLOCK:
+           error(0, "error updating %s: %s", dbname, db_strerror(err));
+           return err;
+         default:
+           fatal(0, "error updating %s: %s", dbname, db_strerror(err));
+         }
+         goto next;
+       }
+     }
+     
      switch(c) {
      case 'V':
        c = getc(fp);
        break;
      case 'E':
        return 0;
-     case 'P':
-     case 'G':
-     case 'U':
-     case 'W':
-       switch(c) {
-       case 'P':
-       which_db = trackdb_prefsdb;
-       which_name = "prefs.db";
-       break;
-       case 'G':
-       which_db = trackdb_globaldb;
-       which_name = "global.db";
-       break;
-       case 'U':
-       which_db = trackdb_usersdb;
-       which_name = "users.db";
-       break;
-       case 'W':                               /* for 'when' */
-       which_db = trackdb_scheduledb;
-       which_name = "scheduledb.db";
-       break;
-       default:
-       abort();
-       }
-       if(undump_dbt(fp, tag, prepare_data(&k))
-          || undump_dbt(fp, tag, prepare_data(&d)))
-         break;
-       switch(err = which_db->put(which_db, tid, &k, &d, 0)) {
-       case 0:
-         break;
-       case DB_LOCK_DEADLOCK:
-         error(0, "error updating %s: %s", which_name, db_strerror(err));
-         return err;
-       default:
-         fatal(0, "error updating %s: %s", which_name, db_strerror(err));
-       }
-       break;
-     case 'T':
-     case 'S':
-       if(undump_dbt(fp, tag, prepare_data(&k))
-          || undump_dbt(fp, tag, prepare_data(&d)))
-         break;
-       /* We don't restore the tracks.db or search.db entries, instead
-        * we recompute them */
-       break;
      case '\n':
        break;
+     default:
+       if(c >= 32 && c <= 126)
+         fatal(0, "unexpected character '%c'", c);
+       else
+         fatal(0, "unexpected character 0x%02X", c);
      }
+   next:
      c = getc(fp);
    }
    if(ferror(fp))
@@@ -435,13 -363,13 +363,13 @@@ fail
  
  int main(int argc, char **argv) {
    int n, dump = 0, undump = 0, recover = TRACKDB_NO_RECOVER, recompute = 0;
-   int tracksdb = 0, searchdb = 0, remove_pathless = 0, fd;
+   int remove_pathless = 0, fd;
    const char *path;
    char *tmp;
    FILE *fp;
  
    mem_init();
-   while((n = getopt_long(argc, argv, "hVc:dDutsrRaP", options, 0)) >= 0) {
+   while((n = getopt_long(argc, argv, "hVc:dDurRaP", options, 0)) >= 0) {
      switch(n) {
      case 'h': help();
      case 'V': version("disorder-dump");
      case 'd': dump = 1; break;
      case 'u': undump = 1; break;
      case 'D': debugging = 1; break;
-     case 't': tracksdb = 1; break;
-     case 's': searchdb = 1; break;
      case 'r': recover = TRACKDB_NORMAL_RECOVER;
      case 'R': recover = TRACKDB_FATAL_RECOVER;
      case 'a': recompute = 1; break;
    }
    if(dump + undump + recompute != 1)
      fatal(0, "choose exactly one of --dump, --undump or --recompute-aliases");
-   if((undump || recompute) && (tracksdb || searchdb))
-     fatal(0, "--trackdb and --searchdb with --undump or --recompute-aliases");
    if(recompute) {
      if(optind != argc)
        fatal(0, "--recompute-aliases does not take a filename");
        fatal(errno, "error opening %s", tmp);
      if(!(fp = fdopen(fd, "w")))
        fatal(errno, "fdopen on %s", tmp);
-     do_dump(fp, tmp, tracksdb, searchdb);
+     do_dump(fp, tmp);
      if(fclose(fp) < 0) fatal(errno, "error closing %s", tmp);
      if(rename(tmp, path) < 0)
        fatal(errno, "error renaming %s to %s", tmp, path);
diff --combined server/server.c
@@@ -2,18 -2,20 +2,18 @@@
   * This file is part of DisOrder.
   * Copyright (C) 2004-2008 Richard Kettlewell
   *
 - * This program is free software; you can redistribute it and/or modify
 + * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
 - * the Free Software Foundation; either version 2 of the License, or
 + * the Free Software Foundation, either version 3 of the License, or
   * (at your option) any later version.
   *
 - * This program is distributed in the hope that it will be useful, but
 - * WITHOUT ANY WARRANTY; without even the implied warranty of
 - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 - * General Public License for more details.
 - *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + * 
   * You should have received a copy of the GNU General Public License
 - * along with this program; if not, write to the Free Software
 - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 - * USA
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
   */
  
  #include "disorder-server.h"
@@@ -39,6 -41,34 +39,34 @@@ struct listener 
    int pf;
  };
  
+ struct conn;
+ /** @brief Signature for line reader callback
+  * @param c Connection
+  * @param line Line
+  * @return 0 if incomplete, 1 if complete
+  *
+  * @p line is 0-terminated and excludes the newline.  It points into the
+  * input buffer so will become invalid shortly.
+  */
+ typedef int line_reader_type(struct conn *c,
+                              char *line);
+ /** @brief Signature for with-body command callbacks
+  * @param c Connection
+  * @param body List of body lines
+  * @param nbody Number of body lines
+  * @param u As passed to fetch_body()
+  * @return 0 to suspend input, 1 if complete
+  *
+  * The body strings are allocated (so survive indefinitely) and don't include
+  * newlines.
+  */
+ typedef int body_callback_type(struct conn *c,
+                                char **body,
+                                int nbody,
+                                void *u);
  /** @brief One client connection */
  struct conn {
    /** @brief Read commands from here */
    struct conn *next;
    /** @brief True if pending rescan had 'wait' set */
    int rescan_wait;
+   /** @brief Playlist that this connection locks */
+   const char *locked_playlist;
+   /** @brief When that playlist was locked */
+   time_t locked_when;
+   /** @brief Line reader function */
+   line_reader_type *line_reader;
+   /** @brief Called when command body has been read */
+   body_callback_type *body_callback;
+   /** @brief Passed to @c body_callback */
+   void *body_u;
+   /** @brief Accumulating body */
+   struct vector body[1];
  };
  
  /** @brief Linked list of connections */
@@@ -83,6 -125,15 +123,15 @@@ static int reader_callback(ev_source *e
                           size_t bytes,
                           int eof,
                           void *u);
+ static int c_playlist_set_body(struct conn *c,
+                                char **body,
+                                int nbody,
+                                void *u);
+ static int fetch_body(struct conn *c,
+                       body_callback_type body_callback,
+                       void *u);
+ static int body_line(struct conn *c, char *line);
+ static int command(struct conn *c, char *line);
  
  static const char *noyes[] = { "no", "yes" };
  
@@@ -188,7 -239,7 +237,7 @@@ static int c_play(struct conn *c, char 
      sink_writes(ev_writer_sink(c->w), "550 cannot resolve track\n");
      return 1;
    }
 -  q = queue_add(track, c->who, WHERE_BEFORE_RANDOM);
 +  q = queue_add(track, c->who, WHERE_BEFORE_RANDOM, origin_picked);
    queue_write();
    /* If we added the first track, and something is playing, then prepare the
     * new track.  If nothing is playing then we don't bother as it wouldn't gain
@@@ -1024,21 -1075,25 +1073,25 @@@ static int c_resolve(struct conn *c
    return 1;
  }
  
- static int c_tags(struct conn *c,
-                 char attribute((unused)) **vec,
-                 int attribute((unused)) nvec) {
-   char **tags = trackdb_alltags();
-   
-   sink_printf(ev_writer_sink(c->w), "253 Tag list follows\n");
-   while(*tags) {
+ static int list_response(struct conn *c,
+                          const char *reply,
+                          char **list) {
+   sink_printf(ev_writer_sink(c->w), "253 %s\n", reply);
+   while(*list) {
      sink_printf(ev_writer_sink(c->w), "%s%s\n",
-               **tags == '.' ? "." : "", *tags);
-     ++tags;
+               **list == '.' ? "." : "", *list);
+     ++list;
    }
    sink_writes(ev_writer_sink(c->w), ".\n");
    return 1;                           /* completed */
  }
  
+ static int c_tags(struct conn *c,
+                 char attribute((unused)) **vec,
+                 int attribute((unused)) nvec) {
+   return list_response(c, "Tag list follows", trackdb_alltags());
+ }
  static int c_set_global(struct conn *c,
                        char **vec,
                        int attribute((unused)) nvec) {
@@@ -1305,17 -1360,7 +1358,7 @@@ static int c_userinfo(struct conn *c
  static int c_users(struct conn *c,
                   char attribute((unused)) **vec,
                   int attribute((unused)) nvec) {
-   /* TODO de-dupe with c_tags */
-   char **users = trackdb_listusers();
-   sink_writes(ev_writer_sink(c->w), "253 User list follows\n");
-   while(*users) {
-     sink_printf(ev_writer_sink(c->w), "%s%s\n",
-               **users == '.' ? "." : "", *users);
-     ++users;
-   }
-   sink_writes(ev_writer_sink(c->w), ".\n");
-   return 1;                           /* completed */
+   return list_response(c, "User list follows", trackdb_listusers());
  }
  
  /** @brief Base64 mapping table for confirmation strings
@@@ -1573,31 -1618,152 +1616,177 @@@ static int c_schedule_add(struct conn *
    return 1;
  }
  
 +static int c_adopt(struct conn *c,
 +                 char **vec,
 +                 int attribute((unused)) nvec) {
 +  struct queue_entry *q;
 +
 +  if(!c->who) {
 +    sink_writes(ev_writer_sink(c->w), "550 no identity\n");
 +    return 1;
 +  }
 +  if(!(q = queue_find(vec[0]))) {
 +    sink_writes(ev_writer_sink(c->w), "550 no such track on the queue\n");
 +    return 1;
 +  }
 +  if(q->origin != origin_random) {
 +    sink_writes(ev_writer_sink(c->w), "550 not a random track\n");
 +    return 1;
 +  }
 +  q->origin = origin_adopted;
 +  q->submitter = xstrdup(c->who);
 +  eventlog("adopted", q->id, q->submitter, (char *)0);
 +  queue_write();
 +  sink_writes(ev_writer_sink(c->w), "250 OK\n");
 +  return 1;
 +}
 +
+ static int playlist_response(struct conn *c,
+                              int err) {
+   switch(err) {
+   case 0:
+     assert(!"cannot cope with success");
+   case EACCES:
+     sink_writes(ev_writer_sink(c->w), "550 Access denied\n");
+     break;
+   case EINVAL:
+     sink_writes(ev_writer_sink(c->w), "550 Invalid playlist name\n");
+     break;
+   case ENOENT:
+     sink_writes(ev_writer_sink(c->w), "555 No such playlist\n");
+     break;
+   default:
+     sink_writes(ev_writer_sink(c->w), "550 Error accessing playlist\n");
+     break;
+   }
+   return 1;
+ }
+ static int c_playlist_get(struct conn *c,
+                         char **vec,
+                         int attribute((unused)) nvec) {
+   char **tracks;
+   int err;
+   if(!(err = trackdb_playlist_get(vec[0], c->who, &tracks, 0, 0)))
+     return list_response(c, "Playlist contents follows", tracks);
+   else
+     return playlist_response(c, err);
+ }
+ static int c_playlist_set(struct conn *c,
+                         char **vec,
+                         int attribute((unused)) nvec) {
+   return fetch_body(c, c_playlist_set_body, vec[0]);
+ }
+ static int c_playlist_set_body(struct conn *c,
+                                char **body,
+                                int nbody,
+                                void *u) {
+   const char *playlist = u;
+   int err;
+   if(!c->locked_playlist
+      || strcmp(playlist, c->locked_playlist)) {
+     sink_writes(ev_writer_sink(c->w), "550 Playlist is not locked\n");
+     return 1;
+   }
+   if(!(err = trackdb_playlist_set(playlist, c->who,
+                                   body, nbody, 0))) {
+     sink_printf(ev_writer_sink(c->w), "250 OK\n");
+     return 1;
+   } else
+     return playlist_response(c, err);
+ }
+ static int c_playlist_get_share(struct conn *c,
+                                 char **vec,
+                                 int attribute((unused)) nvec) {
+   char *share;
+   int err;
+   if(!(err = trackdb_playlist_get(vec[0], c->who, 0, 0, &share))) {
+     sink_printf(ev_writer_sink(c->w), "252 %s\n", quoteutf8(share));
+     return 1;
+   } else
+     return playlist_response(c, err);
+ }
+ static int c_playlist_set_share(struct conn *c,
+                                 char **vec,
+                                 int attribute((unused)) nvec) {
+   int err;
+   if(!(err = trackdb_playlist_set(vec[0], c->who, 0, 0, vec[1]))) {
+     sink_printf(ev_writer_sink(c->w), "250 OK\n");
+     return 1;
+   } else
+     return playlist_response(c, err);
+ }
+ static int c_playlists(struct conn *c,
+                        char attribute((unused)) **vec,
+                        int attribute((unused)) nvec) {
+   char **p;
+   trackdb_playlist_list(c->who, &p, 0);
+   return list_response(c, "List of playlists follows", p);
+ }
+ static int c_playlist_delete(struct conn *c,
+                              char **vec,
+                              int attribute((unused)) nvec) {
+   int err;
+   
+   if(!(err = trackdb_playlist_delete(vec[0], c->who))) {
+     sink_writes(ev_writer_sink(c->w), "250 OK\n");
+     return 1;
+   } else
+     return playlist_response(c, err);
+ }
+ static int c_playlist_lock(struct conn *c,
+                            char **vec,
+                            int attribute((unused)) nvec) {
+   int err;
+   struct conn *cc;
+   /* Check we're allowed to modify this playlist */
+   if((err = trackdb_playlist_set(vec[0], c->who, 0, 0, 0)))
+     return playlist_response(c, err);
+   /* If we hold a lock don't allow a new one */
+   if(c->locked_playlist) {
+     sink_writes(ev_writer_sink(c->w), "550 Already holding a lock\n");
+     return 1;
+   }
+   /* See if some other connection locks the same playlist */
+   for(cc = connections; cc; cc = cc->next)
+     if(cc->locked_playlist && !strcmp(cc->locked_playlist, vec[0]))
+       break;
+   if(cc) {
+     /* TODO: implement config->playlist_lock_timeout */
+     sink_writes(ev_writer_sink(c->w), "550 Already locked\n");
+     return 1;
+   }
+   c->locked_playlist = xstrdup(vec[0]);
+   time(&c->locked_when);
+   sink_writes(ev_writer_sink(c->w), "250 Acquired lock\n");
+   return 1;
+ }
+ static int c_playlist_unlock(struct conn *c,
+                              char attribute((unused)) **vec,
+                              int attribute((unused)) nvec) {
+   if(!c->locked_playlist) {
+     sink_writes(ev_writer_sink(c->w), "550 Not holding a lock\n");
+     return 1;
+   }
+   c->locked_playlist = 0;
+   sink_writes(ev_writer_sink(c->w), "250 Released lock\n");
+   return 1;
+ }
  static const struct command {
    /** @brief Command name */
    const char *name;
    rights_type rights;
  } commands[] = {
    { "adduser",        2, 3,       c_adduser,        RIGHT_ADMIN|RIGHT__LOCAL },
 +  { "adopt",          1, 1,       c_adopt,          RIGHT_PLAY },
    { "allfiles",       0, 2,       c_allfiles,       RIGHT_READ },
    { "confirm",        1, 1,       c_confirm,        0 },
    { "cookie",         1, 1,       c_cookie,         0 },
    { "pause",          0, 0,       c_pause,          RIGHT_PAUSE },
    { "play",           1, 1,       c_play,           RIGHT_PLAY },
    { "playing",        0, 0,       c_playing,        RIGHT_READ },
+   { "playlist-delete",    1, 1,   c_playlist_delete,    RIGHT_PLAY },
+   { "playlist-get",       1, 1,   c_playlist_get,       RIGHT_READ },
+   { "playlist-get-share", 1, 1,   c_playlist_get_share, RIGHT_READ },
+   { "playlist-lock",      1, 1,   c_playlist_lock,      RIGHT_PLAY },
+   { "playlist-set",       1, 1,   c_playlist_set,       RIGHT_PLAY },
+   { "playlist-set-share", 2, 2,   c_playlist_set_share, RIGHT_PLAY },
+   { "playlist-unlock",    0, 0,   c_playlist_unlock,    RIGHT_PLAY },
+   { "playlists",          0, 0,   c_playlists,          RIGHT_READ },
    { "prefs",          1, 1,       c_prefs,          RIGHT_READ },
    { "queue",          0, 0,       c_queue,          RIGHT_READ },
    { "random-disable", 0, 0,       c_random_disable, RIGHT_GLOBAL_PREFS },
    { "volume",         0, 2,       c_volume,         RIGHT_READ|RIGHT_VOLUME }
  };
  
+ /** @brief Fetch a command body
+  * @param c Connection
+  * @param body_callback Called with body
+  * @param u Passed to body_callback
+  * @return 1
+  */
+ static int fetch_body(struct conn *c,
+                       body_callback_type body_callback,
+                       void *u) {
+   assert(c->line_reader == command);
+   c->line_reader = body_line;
+   c->body_callback = body_callback;
+   c->body_u = u;
+   vector_init(c->body);
+   return 1;
+ }
+ /** @brief @ref line_reader_type callback for command body lines
+  * @param c Connection
+  * @param line Line
+  * @return 1 if complete, 0 if incomplete
+  *
+  * Called from reader_callback().
+  */
+ static int body_line(struct conn *c,
+                      char *line) {
+   if(*line == '.') {
+     ++line;
+     if(!*line) {
+       /* That's the lot */
+       c->line_reader = command;
+       vector_terminate(c->body);
+       return c->body_callback(c, c->body->vec, c->body->nvec, c->body_u);
+     }
+   }
+   vector_append(c->body, xstrdup(line));
+   return 1;                             /* completed */
+ }
  static void command_error(const char *msg, void *u) {
    struct conn *c = u;
  
    sink_printf(ev_writer_sink(c->w), "500 parse error: %s\n", msg);
  }
  
- /* process a command.  Return 1 if complete, 0 if incomplete. */
+ /** @brief @ref line_reader_type callback for commands
+  * @param c Connection
+  * @param line Line
+  * @return 1 if complete, 0 if incomplete
+  *
+  * Called from reader_callback().
+  */
  static int command(struct conn *c, char *line) {
    char **vec;
    int nvec, n;
@@@ -1756,7 -1974,7 +1998,7 @@@ static int reader_callback(ev_source at
    while((eol = memchr(ptr, '\n', bytes))) {
      *eol++ = 0;
      ev_reader_consume(reader, eol - (char *)ptr);
-     complete = command(c, ptr);
+     complete = c->line_reader(c, ptr);  /* usually command() */
      bytes -= (eol - (char *)ptr);
      ptr = eol;
      if(!complete) {
@@@ -1802,23 -2020,14 +2044,24 @@@ static int listen_callback(ev_source *e
    c->ev = ev;
    c->w = ev_writer_new(ev, fd, writer_error, c,
                       "client writer");
 +  if(!c->w) {
 +    error(0, "ev_writer_new for file inbound connection (fd=%d) failed",
 +          fd);
 +    close(fd);
 +    return 0;
 +  }
    c->r = ev_reader_new(ev, fd, redirect_reader_callback, reader_error, c,
                       "client reader");
 +  if(!c->r)
 +    /* Main reason for failure is the FD is too big and that will already have
 +     * been handled */
 +    fatal(0, "ev_reader_new for file inbound connection (fd=%d) failed", fd);
    ev_tie(c->r, c->w);
    c->fd = fd;
    c->reader = reader_callback;
    c->l = l;
    c->rights = 0;
+   c->line_reader = command;
    connections = c;
    gcry_randomize(c->nonce, sizeof c->nonce, GCRY_STRONG_RANDOM);
    sink_printf(ev_writer_sink(c->w), "231 %d %s %s\n",
diff --combined tests/Makefile.am
@@@ -2,18 -2,20 +2,18 @@@
  # This file is part of DisOrder.
  # Copyright (C) 2004, 2005, 2007, 2008 Richard Kettlewell
  #
 -# This program is free software; you can redistribute it and/or modify
 +# This program is free software: you can redistribute it and/or modify
  # it under the terms of the GNU General Public License as published by
 -# the Free Software Foundation; either version 2 of the License, or
 +# the Free Software Foundation, either version 3 of the License, or
  # (at your option) any later version.
  #
 -# This program is distributed in the hope that it will be useful, but
 -# WITHOUT ANY WARRANTY; without even the implied warranty of
 -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 -# General Public License for more details.
 -#
 +# This program is distributed in the hope that it will be useful,
 +# but WITHOUT ANY WARRANTY; without even the implied warranty of
 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 +# GNU General Public License for more details.
 +# 
  # You should have received a copy of the GNU General Public License
 -# along with this program; if not, write to the Free Software
 -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 -# USA
 +# along with this program.  If not, see <http://www.gnu.org/licenses/>.
  #
  
  noinst_PROGRAMS=disorder-udplog
@@@ -26,7 -28,7 +26,7 @@@ disorder_udplog_DEPENDENCIES=../lib/lib
  
  TESTS=cookie.py dbversion.py dump.py files.py play.py queue.py        \
        recode.py search.py user-upgrade.py user.py aliases.py  \
-       schedule.py
+       schedule.py playlists.py
  
  TESTS_ENVIRONMENT=${PYTHON} -u
  
@@@ -34,4 -36,3 +34,4 @@@ clean-local
        rm -rf testroot *.log *.pyc
  
  EXTRA_DIST=dtest.py ${TESTS}
 +CLEANFILES=*.gcda *.gcov *.gcno *.c.html index.html