chiark / gitweb /
Merge playlist support.
authorRichard Kettlewell <rjk@greenend.org.uk>
Sat, 10 Oct 2009 20:03:23 +0000 (21:03 +0100)
committerRichard Kettlewell <rjk@greenend.org.uk>
Sat, 10 Oct 2009 20:03:23 +0000 (21:03 +0100)
16 files changed:
1  2 
clients/disorder.c
disobedience/Makefile.am
disobedience/disobedience.c
disobedience/disobedience.h
disobedience/menu.c
doc/disorder.1.in
doc/disorder_protocol.5.in
lib/Makefile.am
lib/client.c
lib/configuration.c
lib/configuration.h
lib/trackdb.c
python/disorder.py.in
server/dump.c
server/server.c
tests/Makefile.am

diff --combined clients/disorder.c
index ccfaf20cb2d2613757ed37bf3eb0846e5fac946e,4161d939cb141ef6e294a610f1a197d2267123b8..a246eee9950eaf9436801abe4f1bed55bc09ad80
@@@ -32,7 -32,7 +32,8 @@@
  #include <unistd.h>
  #include <pcre.h>
  #include <ctype.h>
 +#include <gcrypt.h>
+ #include <langinfo.h>
  
  #include "configuration.h"
  #include "syscalls.h"
@@@ -51,7 -51,7 +52,8 @@@
  #include "vector.h"
  #include "version.h"
  #include "dateparse.h"
 +#include "trackdb.h"
+ #include "inputline.h"
  
  static disorder_client *client;
  
@@@ -65,7 -65,6 +67,7 @@@ static const struct option options[] = 
    { "help-commands", no_argument, 0, 'H' },
    { "user", required_argument, 0, 'u' },
    { "password", required_argument, 0, 'p' },
 +  { "wait-for-root", no_argument, 0, 'W' },
    { 0, 0, 0, 0 }
  };
  
@@@ -160,7 -159,7 +162,7 @@@ static void cf_shutdown(char attribute(
  
  static void cf_reconfigure(char attribute((unused)) **argv) {
    /* Re-check configuration for server */
 -  if(config_read(1)) fatal(0, "cannot read configuration");
 +  if(config_read(1, NULL)) fatal(0, "cannot read configuration");
    if(disorder_reconfigure(getclient())) exit(EXIT_FAILURE);
  }
  
@@@ -188,15 -187,34 +190,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,
@@@ -585,6 -603,61 +606,61 @@@ static void cf_adopt(char **argv) 
      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;
                        "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 },
@@@ -730,28 -811,8 +814,28 @@@ static void help_commands(void) 
    exit(0);
  }
  
 +static void wait_for_root(void) {
 +  const char *password;
 +
 +  while(!trackdb_readable()) {
 +    info("waiting for trackdb...");
 +    sleep(1);
 +  }
 +  trackdb_init(TRACKDB_NO_RECOVER|TRACKDB_NO_UPGRADE);
 +  for(;;) {
 +    trackdb_open(TRACKDB_READ_ONLY);
 +    password = trackdb_get_password("root");
 +    trackdb_close();
 +    if(password)
 +      break;
 +    info("waiting for root user to be created...");
 +    sleep(1);
 +  }
 +  trackdb_deinit();
 +}
 +
  int main(int argc, char **argv) {
 -  int n, i, j, local = 0;
 +  int n, i, j, local = 0, wfr = 0;
    int status = 0;
    struct vector args;
    const char *user = 0, *password = 0;
    pcre_free = xfree;
    if(!setlocale(LC_CTYPE, "")) fatal(errno, "error calling setlocale");
    if(!setlocale(LC_TIME, "")) fatal(errno, "error calling setlocale");
 -  while((n = getopt_long(argc, argv, "+hVc:dHlNu:p:", options, 0)) >= 0) {
 +  while((n = getopt_long(argc, argv, "+hVc:dHlNu:p:W", options, 0)) >= 0) {
      switch(n) {
      case 'h': help();
      case 'H': help_commands();
      case 'N': config_per_user = 0; break;
      case 'u': user = optarg; break;
      case 'p': password = optarg; break;
 +    case 'W': wfr = 1; break;
      default: fatal(0, "invalid option");
      }
    }
 -  if(config_read(0)) fatal(0, "cannot read configuration");
 +  if(config_read(0, NULL)) fatal(0, "cannot read configuration");
    if(user) {
      config->username = user;
      config->password = 0;
    if(password)
      config->password = password;
    if(local)
 -    config->connect.n = 0;
 +    config->connect.af = -1;
 +  if(wfr)
 +    wait_for_root();
    n = optind;
    optind = 1;                         /* for subsequent getopt calls */
 +  /* gcrypt initialization */
 +  if(!gcry_check_version(NULL))
 +    disorder_fatal(0, "gcry_check_version failed");
 +  gcry_control(GCRYCTL_INIT_SECMEM, 0);
 +  gcry_control (GCRYCTL_INITIALIZATION_FINISHED, 0);
    /* accumulate command args */
    while(n < argc) {
      if((i = TABLE_FIND(commands, name, argv[n])) < 0)
diff --combined disobedience/Makefile.am
index c7b702bc32ca5bb0a6948ba5932d997b9ee9d596,94a4c786e9b146d5a18d117dc200ec4a4bdf00f3..f8bdb140962dde17fa768bf4f75eac13625ca445
@@@ -1,6 -1,6 +1,6 @@@
  #
  # This file is part of DisOrder.
 -# Copyright (C) 2006-2008 Richard Kettlewell
 +# Copyright (C) 2006-2009 Richard Kettlewell
  #
  # 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
@@@ -28,9 -28,9 +28,9 @@@ disobedience_SOURCES=disobedience.h dis
        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 choose.h      \
-       popup.h
+       popup.h playlists.c
  disobedience_LDADD=../lib/libdisorder.a $(LIBPCRE) $(LIBGC) $(LIBGCRYPT) \
 -      $(LIBASOUND) $(COREAUDIO) $(LIBDB)
 +      $(LIBASOUND) $(COREAUDIO) $(LIBDB) $(LIBICONV)
  disobedience_LDFLAGS=$(GTK_LIBS)
  
  install-exec-hook:
index 38fc6eb33ef8708fe690a47d11f9c3cd40ba90fc,1f9ee43d3d6e4d9a81cfac76ad4f64e2dac5efba..2b3194151b1c90fbf42f21d913e3d245f5a4f263
@@@ -1,6 -1,6 +1,6 @@@
  /*
   * This file is part of DisOrder.
 - * Copyright (C) 2006, 2007, 2008 Richard Kettlewell
 + * Copyright (C) 2006-2009 Richard Kettlewell
   *
   * 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
   */
  
  #include "disobedience.h"
 -#include "mixer.h"
  #include "version.h"
  
  #include <getopt.h>
  #include <locale.h>
  #include <pcre.h>
 +#include <gcrypt.h>
  
  /* Apologies for the numerous de-consting casts, but GLib et al do not seem to
   * have heard of const. */
@@@ -68,9 -68,6 +68,9 @@@ int volume_l
  /** @brief Right channel volume */
  int volume_r;
  
 +/** @brief Audio backend */
 +const struct uaudio *backend;
 +
  double goesupto = 10;                   /* volume upper bound */
  
  /** @brief True if a NOP is in flight */
@@@ -243,6 -240,7 +243,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 */
@@@ -266,10 -264,10 +267,10 @@@ static gboolean periodic_fast(gpointer 
    }
    last = now;
  #endif
 -  if(rtp_supported && mixer_supported(DEFAULT_BACKEND)) {
 +  if(rtp_supported && backend && backend->get_volume) {
      int nl, nr;
 -    if(!mixer_control(DEFAULT_BACKEND, &nl, &nr, 0)
 -       && (nl != volume_l || nr != volume_r)) {
 +    backend->get_volume(&nl, &nr);
 +    if(nl != volume_l || nr != volume_r) {
        volume_l = nl;
        volume_r = nr;
        event_raise("volume-changed", 0);
      recheck_rights = 0;
    if(recheck_rights)
      check_rights();
+   event_raise("periodic-fast", 0);
    return TRUE;
  }
  
@@@ -449,24 -448,13 +451,24 @@@ int main(int argc, char **argv) 
    }
    if(!gtkok)
      fatal(0, "failed to initialize GTK+");
 +  /* gcrypt initialization */
 +  if(!gcry_check_version(NULL))
 +    disorder_fatal(0, "gcry_check_version failed");
 +  gcry_control(GCRYCTL_INIT_SECMEM, 0);
 +  gcry_control (GCRYCTL_INITIALIZATION_FINISHED, 0);
    signal(SIGPIPE, SIG_IGN);
    init_styles();
    load_settings();
    /* create the event loop */
    D(("create main loop"));
    mainloop = g_main_loop_new(0, 0);
 -  if(config_read(0)) fatal(0, "cannot read configuration");
 +  if(config_read(0, NULL)) fatal(0, "cannot read configuration");
 +  /* we'll need mixer support */
 +  backend = uaudio_apis[0];
 +  if(backend->configure)
 +    backend->configure();
 +  if(backend->open_mixer)
 +    backend->open_mixer();
    /* create the clients */
    if(!(client = gtkclient())
       || !(logclient = gtkclient()))
    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();
index ca5f7eff32ba7c6a2d2e91a0e911f6fcb39409d5,46877245e29cfbbb03703d601a885a8ac9fbd82b..e34d3838b11022109caf48d79bbadb7baae80e39
@@@ -47,7 -47,6 +47,7 @@@
  #include "eventdist.h"
  #include "split.h"
  #include "timeval.h"
 +#include "uaudio.h"
  
  #include <glib.h>
  #include <gtk/gtk.h>
@@@ -105,7 -104,6 +105,7 @@@ extern GtkTooltips *tips
  extern int rtp_supported;
  extern int rtp_is_running;
  extern GtkItemFactory *mainmenufactory;
 +extern const struct uaudio *backend;
  
  extern const disorder_eclient_log_callbacks log_callbacks;
  
@@@ -252,6 -250,18 +252,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/menu.c
index 15fb4fb9a4dc5ebf61c34cd3f487f8c0134eef35,1d50223d3dfff0c9192a06c800552c480da1eada..f8bd58ef43e9d4904e8838e3b14fdeedb72da3af
@@@ -24,6 -24,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 -116,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,
@@@ -173,7 -176,7 +176,7 @@@ static void about_popup_got_version(voi
                       FALSE/*fill*/,
                       1/*padding*/);
    gtk_box_pack_start(GTK_BOX(vbox),
 -                     gtk_label_new("\xC2\xA9 2004-2008 Richard Kettlewell"),
 +                     gtk_label_new("\xC2\xA9 2004-2009 Richard Kettlewell"),
                       FALSE/*expand*/,
                       FALSE/*fill*/,
                       1/*padding*/);
@@@ -293,6 -296,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,
diff --combined doc/disorder.1.in
index afa1eaa800b87d56d8de89869a94a33ef74b873c,784b37a99d5c78e9d1ab85075611bba3985e23a8..b61a825a5ff64fec5f5cae88836017fd34d11547
@@@ -152,6 -152,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).
@@@ -173,11 -190,6 +190,11 @@@ recently played one
  .TP
  .B reconfigure
  Make the daemon reload its configuration file.
 +.IP
 +Not all configuration options can be modified during the lifetime of the
 +server; of those that can't, some will just be ignored if they change while
 +others will cause the new configuration to be rejected.
 +See \fBdisorder_config\fR(5) for details.
  .TP
  .B remove \fITRACK\fR
  Remove a track from the queue.
index b42ebd5f4ec5e0c88c7e16a2af4e44eaa07fe980,4ad1d42bc5bcffe69f8c31a4fa0be13df293f3bf..a0baadba4089bd15aaedb52ff5f3f55cf359dfb8
@@@ -38,6 -38,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 -56,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.
@@@ -208,6 -215,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
@@@ -238,11 -282,6 +282,11 @@@ See below for the track information syn
  .B reconfigure
  Request that DisOrder reconfigure itself.
  Requires the \fBadmin\fR right.
 +.IP
 +Not all configuration options can be modified during the lifetime of the
 +server; of those that can't, some will just be ignored if they change while
 +others will cause the new configuration to be rejected.
 +See \fBdisorder_config\fR(5) for details.
  .TP
  .B register \fIUSERNAME PASSWORD EMAIL
  Register a new user.
@@@ -598,6 -637,21 +642,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
index 4717fd616b7b59c48cba9cbaf0bc0a6e178558ff,ca0e82d194ac14af7ba631983e0533aa13f94e23..d0890e1ce936ab8b29adedbdb9cb8be18beeee13
@@@ -1,6 -1,6 +1,6 @@@
  #
  # This file is part of DisOrder.
 -# Copyright (C) 2004-2008 Richard Kettlewell
 +# Copyright (C) 2004-2009 Richard Kettlewell
  #
  # 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
@@@ -27,6 -27,7 +27,6 @@@ endi
  
  libdisorder_a_SOURCES=charset.c charset.h             \
        addr.c addr.h                                   \
 -      alsabg.c alsabg.h                               \
        arcfour.c arcfour.h                             \
        authhash.c authhash.h                           \
        basen.c basen.h                                 \
@@@ -38,7 -39,6 +38,7 @@@
        client-common.c client-common.h                 \
        configuration.c configuration.h                 \
        cookies.c cookies.h                             \
 +      coreaudio.c coreaudio.h                         \
        dateparse.c dateparse.h xgetdate.c              \
        defs.c defs.h                                   \
        eclient.c eclient.h                             \
        ifreq.c ifreq.h                                 \
        inputline.c inputline.h                         \
        kvp.c kvp.h                                     \
 -      log.c log.h log-impl.h                          \
 +      log.c log.h                                     \
        logfd.c logfd.h                                 \
        macros.c macros-builtin.c macros.h              \
 -      mem.c mem.h mem-impl.h                          \
 +      mem.c mem.h                                     \
        mime.h mime.c                                   \
 -      mixer.c mixer.h mixer-oss.c mixer-alsa.c        \
        printf.c printf.h                               \
        asprintf.c fprintf.c snprintf.c                 \
        queue.c queue.h                                 \
        table.c table.h                                 \
        timeval.h                                       \
        $(TRACKDB) trackdb.h trackdb-int.h              \
+       trackdb-playlists.c                             \
        trackname.c trackorder.c trackname.h            \
        tracksort.c                                     \
 +      uaudio.c uaudio-thread.c uaudio.h uaudio-apis.c \
 +      uaudio-oss.c uaudio-alsa.c                      \
 +      uaudio-coreaudio.c                              \
 +      uaudio-rtp.c uaudio-command.c uaudio-schedule.c \
        url.h url.c                                     \
        user.h user.c                                   \
        unicode.h unicode.c                             \
diff --combined lib/client.c
index 20db6759dc52f743cba1f047212763198e6cf7f7,f8a2c9e39f84ba768d630be57cd38ee284136d00..a0502eb49fec87ff3bb259ec115397a41ab1aed9
@@@ -153,6 -153,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 -250,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;
  }
@@@ -396,7 -451,7 +451,7 @@@ int disorder_connect(disorder_client *c
    if(!password) {
      /* Oh well */
      c->last = "no password";
 -    error(0, "no password configured");
 +    error(0, "no password configured for user '%s'", username);
      return -1;
    }
    return disorder_connect_generic(config,
@@@ -670,6 -725,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);
@@@ -1311,6 -1368,103 +1368,103 @@@ int disorder_adopt(disorder_client *c, 
    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/configuration.c
index dc3d0092b6c8216081b4e794a848cf6f15362826,cd83224cb50138a678b80a7f35a462f714abfeef..ff59968fac21cbb8b18a176250284af6029e6b30
@@@ -1,6 -1,6 +1,6 @@@
  /*
   * This file is part of DisOrder.
 - * Copyright (C) 2004-2008 Richard Kettlewell
 + * Copyright (C) 2004-2009 Richard Kettlewell
   * Portions copyright (C) 2007 Mark Wooding
   *
   * This program is free software: you can redistribute it and/or modify
  #include "inputline.h"
  #include "charset.h"
  #include "defs.h"
 -#include "mixer.h"
  #include "printf.h"
  #include "regsub.h"
  #include "signame.h"
  #include "authhash.h"
  #include "vector.h"
 +#include "uaudio.h"
  
  /** @brief Path to config file 
   *
@@@ -62,12 -62,6 +62,12 @@@ char *configfile
   */
  int config_per_user = 1;
  
 +/** @brief Table of audio APIs
 + *
 + * Only set in server processes.
 + */
 +const struct uaudio *const *config_uaudio_apis;
 +
  /** @brief Config file parser state */
  struct config_state {
    /** @brief Filename */
@@@ -109,11 -103,6 +109,11 @@@ struct conftype 
  /** @brief Return the value of an item */
  #define VALUE(C, TYPE) (*ADDRESS(C, TYPE))
  
 +static int stringlist_compare(const struct stringlist *a,
 +                              const struct stringlist *b);
 +static int namepartlist_compare(const struct namepartlist *a,
 +                                const struct namepartlist *b);
 +
  static int set_signal(const struct config_state *cs,
                      const struct conf *whoami,
                      int nvec, char **vec) {
@@@ -425,7 -414,6 +425,7 @@@ static int set_namepart(const struct co
    npl->s = xrealloc(npl->s, (npl->n + 1) * sizeof (struct namepart));
    npl->s[npl->n].part = xstrdup(vec[0]);
    npl->s[npl->n].re = re;
 +  npl->s[npl->n].res = xstrdup(vec[1]);
    npl->s[npl->n].replace = xstrdup(vec[2]);
    npl->s[npl->n].context = xstrdup(vec[3]);
    npl->s[npl->n].reflags = reflags;
@@@ -478,6 -466,52 +478,6 @@@ static int set_transform(const struct c
    return 0;
  }
  
 -static int set_backend(const struct config_state *cs,
 -                     const struct conf *whoami,
 -                     int nvec, char **vec) {
 -  int *const valuep = ADDRESS(cs->config, int);
 -  
 -  if(nvec != 1) {
 -    error(0, "%s:%d: '%s' requires one argument",
 -        cs->path, cs->line, whoami->name);
 -    return -1;
 -  }
 -  if(!strcmp(vec[0], "alsa")) {
 -#if HAVE_ALSA_ASOUNDLIB_H
 -    *valuep = BACKEND_ALSA;
 -#else
 -    error(0, "%s:%d: ALSA is not available on this platform",
 -        cs->path, cs->line);
 -    return -1;
 -#endif
 -  } else if(!strcmp(vec[0], "command"))
 -    *valuep = BACKEND_COMMAND;
 -  else if(!strcmp(vec[0], "network"))
 -    *valuep = BACKEND_NETWORK;
 -  else if(!strcmp(vec[0], "coreaudio")) {
 -#if HAVE_COREAUDIO_AUDIOHARDWARE_H
 -    *valuep = BACKEND_COREAUDIO;
 -#else
 -    error(0, "%s:%d: Core Audio is not available on this platform",
 -        cs->path, cs->line);
 -    return -1;
 -#endif
 -  } else if(!strcmp(vec[0], "oss")) {
 -#if HAVE_SYS_SOUNDCARD_H
 -    *valuep = BACKEND_OSS;
 -#else
 -    error(0, "%s:%d: OSS is not available on this platform",
 -        cs->path, cs->line);
 -    return -1;
 -#endif
 -  } else {
 -    error(0, "%s:%d: invalid '%s' value '%s'",
 -        cs->path, cs->line, whoami->name, vec[0]);
 -    return -1;
 -  }
 -  return 0;
 -}
 -
  static int set_rights(const struct config_state *cs,
                      const struct conf *whoami,
                      int nvec, char **vec) {
    return 0;
  }
  
 +static int set_netaddress(const struct config_state *cs,
 +                        const struct conf *whoami,
 +                        int nvec, char **vec) {
 +  struct netaddress *na = ADDRESS(cs->config, struct netaddress);
 +
 +  if(netaddress_parse(na, nvec, vec)) {
 +    error(0, "%s:%d: invalid network address", cs->path, cs->line);
 +    return -1;
 +  }
 +  return 0;
 +}
 +
  /* free functions */
  
  static void free_none(struct config attribute((unused)) *c,
  static void free_string(struct config *c,
                        const struct conf *whoami) {
    xfree(VALUE(c, char *));
 +  VALUE(c, char *) = 0;
  }
  
  static void free_stringlist(struct config *c,
@@@ -569,7 -590,6 +569,7 @@@ static void free_namepartlist(struct co
      np = &npl->s[n];
      xfree(np->part);
      pcre_free(np->re);                        /* ...whatever pcre_free is set to. */
 +    xfree(np->res);
      xfree(np->replace);
      xfree(np->context);
    }
@@@ -592,13 -612,6 +592,13 @@@ static void free_transformlist(struct c
    xfree(tl->t);
  }
  
 +static void free_netaddress(struct config *c,
 +                          const struct conf *whoami) {
 +  struct netaddress *na = ADDRESS(c, struct netaddress);
 +
 +  xfree(na->address);
 +}
 +
  /* configuration types */
  
  static const struct conftype
    type_restrict = { set_restrict, free_none },
    type_namepart = { set_namepart, free_namepartlist },
    type_transform = { set_transform, free_transformlist },
 -  type_rights = { set_rights, free_none },
 -  type_backend = { set_backend, free_none };
 +  type_netaddress = { set_netaddress, free_netaddress },
 +  type_rights = { set_rights, free_none };
  
  /* specific validation routine */
  
@@@ -841,63 -854,54 +841,63 @@@ static int validate_alias(const struct 
    return 0;
  }
  
 -static int validate_addrport(const struct config_state attribute((unused)) *cs,
 -                           int nvec,
 -                           char attribute((unused)) **vec) {
 -  switch(nvec) {
 -  case 0:
 -    error(0, "%s:%d: missing address",
 -        cs->path, cs->line);
 -    return -1;
 -  case 1:
 -    error(0, "%s:%d: missing port name/number",
 -        cs->path, cs->line);
 +static int validate_algo(const struct config_state attribute((unused)) *cs,
 +                       int nvec,
 +                       char **vec) {
 +  if(nvec != 1) {
 +    error(0, "%s:%d: invalid algorithm specification", cs->path, cs->line);
      return -1;
 -  case 2:
 -    return 0;
 -  default:
 -    error(0, "%s:%d: expected ADDRESS PORT",
 -        cs->path, cs->line);
 +  }
 +  if(!valid_authhash(vec[0])) {
 +    error(0, "%s:%d: unsuported algorithm '%s'", cs->path, cs->line, vec[0]);
      return -1;
    }
 +  return 0;
  }
  
 -static int validate_port(const struct config_state attribute((unused)) *cs,
 -                       int nvec,
 -                       char attribute((unused)) **vec) {
 -  switch(nvec) {
 -  case 0:
 -    error(0, "%s:%d: missing address",
 -        cs->path, cs->line);
 +static int validate_backend(const struct config_state attribute((unused)) *cs,
 +                            int nvec,
 +                            char **vec) {
 +  int n;
 +  if(nvec != 1) {
 +    error(0, "%s:%d: invalid sound API specification", cs->path, cs->line);
      return -1;
 -  case 1:
 -  case 2:
 +  }
 +  if(!strcmp(vec[0], "network")) {
 +    error(0, "'api network' is deprecated; use 'api rtp'");
      return 0;
 -  default:
 -    error(0, "%s:%d: expected [ADDRESS] PORT",
 -        cs->path, cs->line);
 +  }
 +  if(config_uaudio_apis) {
 +    for(n = 0; config_uaudio_apis[n]; ++n)
 +      if(!strcmp(vec[0], config_uaudio_apis[n]->name))
 +        return 0;
 +    error(0, "%s:%d: unrecognized sound API '%s'", cs->path, cs->line, vec[0]);
      return -1;
    }
 +  /* In non-server processes we have no idea what's valid */
 +  return 0;
  }
  
 -static int validate_algo(const struct config_state attribute((unused)) *cs,
 -                       int nvec,
 -                       char **vec) {
 -  if(nvec != 1) {
 -    error(0, "%s:%d: invalid algorithm specification", cs->path, cs->line);
 +static int validate_pausemode(const struct config_state attribute((unused)) *cs,
 +                              int nvec,
 +                              char **vec) {
 +  if(nvec == 1 && (!strcmp(vec[0], "silence") || !strcmp(vec[0], "suspend")))
 +    return 0;
 +  error(0, "%s:%d: invalid pause mode", cs->path, cs->line);
 +  return -1;
 +}
 +
 +static int validate_destaddr(const struct config_state attribute((unused)) *cs,
 +                           int nvec,
 +                           char **vec) {
 +  struct netaddress na[1];
 +
 +  if(netaddress_parse(na, nvec, vec)) {
 +    error(0, "%s:%d: invalid network address", cs->path, cs->line);
      return -1;
    }
 -  if(!valid_authhash(vec[0])) {
 -    error(0, "%s:%d: unsuported algorithm '%s'", cs->path, cs->line, vec[0]);
 +  if(!na->address) {
 +    error(0, "%s:%d: destination address required", cs->path, cs->line);
      return -1;
    }
    return 0;
  static const struct conf conf[] = {
    { C(alias),            &type_string,           validate_alias },
    { C(allow),            &type_stringlist_accum, validate_allow },
 -  { C(api),              &type_backend,          validate_any },
 +  { C(api),              &type_string,           validate_backend },
    { C(authorization_algorithm), &type_string,    validate_algo },
 -  { C(broadcast),        &type_stringlist,       validate_addrport },
 -  { C(broadcast_from),   &type_stringlist,       validate_addrport },
 +  { C(broadcast),        &type_netaddress,       validate_destaddr },
 +  { C(broadcast_from),   &type_netaddress,       validate_any },
    { C(channel),          &type_string,           validate_any },
    { C(checkpoint_kbyte), &type_integer,          validate_non_negative },
    { C(checkpoint_min),   &type_integer,          validate_non_negative },
    { C(collection),       &type_collections,      validate_any },
 -  { C(connect),          &type_stringlist,       validate_addrport },
 +  { C(connect),          &type_netaddress,       validate_destaddr },
    { C(cookie_login_lifetime),  &type_integer,    validate_positive },
    { C(cookie_key_lifetime),  &type_integer,      validate_positive },
    { C(dbversion),        &type_integer,          validate_positive },
    { C(gap),              &type_integer,          validate_non_negative },
    { C(history),          &type_integer,          validate_positive },
    { C(home),             &type_string,           validate_isabspath },
 -  { C(listen),           &type_stringlist,       validate_port },
 +  { C(listen),           &type_netaddress,       validate_any },
    { C(lock),             &type_boolean,          validate_any },
    { C(mail_sender),      &type_string,           validate_any },
    { C(mixer),            &type_string,           validate_any },
    { C(nice_speaker),     &type_integer,          validate_any },
    { C(noticed_history),  &type_integer,          validate_positive },
    { C(password),         &type_string,           validate_any },
 +  { C(pause_mode),       &type_string,           validate_pausemode },
    { 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 },
    { C(reminder_interval), &type_integer,         validate_positive },
    { C(remote_userman),   &type_boolean,          validate_any },
    { C2(restrict, restrictions),         &type_restrict,         validate_any },
 +  { C(rtp_delay_threshold), &type_integer,       validate_positive },
    { C(sample_format),    &type_sample_format,    validate_sample_format },
    { C(scratch),          &type_string_accum,     validate_isreg },
    { C(sendmail),         &type_string,           validate_isabspath },
    { C(signal),           &type_signal,           validate_any },
    { C(smtp_server),      &type_string,           validate_any },
    { C(sox_generation),   &type_integer,          validate_non_negative },
 -  { C2(speaker_backend, api),  &type_backend,          validate_any },
 +  { C2(speaker_backend, api),  &type_string,     validate_backend },
    { C(speaker_command),  &type_string,           validate_any },
    { C(stopword),         &type_string_accum,     validate_any },
    { C(templates),        &type_string_accum,     validate_isdir },
@@@ -1163,7 -1167,7 +1165,7 @@@ static struct config *config_default(vo
    logname = pw->pw_name;
    c->username = xstrdup(logname);
    c->refresh = 15;
 -  c->prefsync = 3600;
 +  c->prefsync = 0;
    c->signal = SIGKILL;
    c->alias = xstrdup("{/artist}{/album}{/title}{ext}");
    c->lock = 1;
    c->sample_format.endian = ENDIAN_NATIVE;
    c->queue_pad = 10;
    c->replay_min = 8 * 3600;
 -  c->api = -1;
 +  c->api = NULL;
    c->multicast_ttl = 1;
    c->multicast_loop = 1;
    c->authorization_algorithm = xstrdup("sha1");
    c->new_bias_age = 7 * 86400;                /* 1 week */
    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);
                       default_players[n], "disorder-tracklength", (char *)0))
        exit(1);
    }
 +  c->broadcast.af = -1;
 +  c->broadcast_from.af = -1;
 +  c->listen.af = -1;
 +  c->connect.af = -1;
    return c;
  }
  
@@@ -1279,36 -1281,34 +1283,36 @@@ static void config_postdefaults(struct 
      for(n = 0; n < NTRANSFORM; ++n)
        set_transform(&cs, whoami, 5, (char **)transform[n]);
    }
 -  if(c->api == -1) {
 +  if(!c->api) {
      if(c->speaker_command)
 -      c->api = BACKEND_COMMAND;
 -    else if(c->broadcast.n)
 -      c->api = BACKEND_NETWORK;
 +      c->api = xstrdup("command");
 +    else if(c->broadcast.af != -1)
 +      c->api = xstrdup("rtp");
 +    else if(config_uaudio_apis)
 +      c->api = xstrdup(config_uaudio_apis[0]->name);
      else
 -      c->api = DEFAULT_BACKEND;
 +      c->api = xstrdup("<none>");
    }
 +  if(!strcmp(c->api, "network"))
 +    c->api = xstrdup("rtp");
    if(server) {
 -    if(c->api == BACKEND_COMMAND && !c->speaker_command)
 +    if(!strcmp(c->api, "command") && !c->speaker_command)
        fatal(0, "'api command' but speaker_command is not set");
 -    if(c->api == BACKEND_NETWORK && !c->broadcast.n)
 -      fatal(0, "'api network' but broadcast is not set");
 +    if((!strcmp(c->api, "rtp")) && c->broadcast.af == -1)
 +      fatal(0, "'api rtp' but broadcast is not set");
    }
    /* Override sample format */
 -  switch(c->api) {
 -  case BACKEND_NETWORK:
 +  if(!strcmp(c->api, "rtp")) {
      c->sample_format.rate = 44100;
      c->sample_format.channels = 2;
      c->sample_format.bits = 16;
 -    c->sample_format.endian = ENDIAN_BIG;
 -    break;
 -  case BACKEND_COREAUDIO:
 +    c->sample_format.endian = ENDIAN_NATIVE;
 +  }
 +  if(!strcmp(c->api, "coreaudio")) {
      c->sample_format.rate = 44100;
      c->sample_format.channels = 2;
      c->sample_format.bits = 16;
      c->sample_format.endian = ENDIAN_NATIVE;
 -    break; 
    }
    if(!c->default_rights) {
      rights_type r = RIGHTS__MASK & ~(RIGHT_ADMIN|RIGHT_REGISTER
  
  /** @brief (Re-)read the config file
   * @param server If set, do extra checking
 + * @param oldconfig Old configuration for compatibility check
 + * @return 0 on success, non-0 on error
 + *
 + * If @p oldconfig is set, then certain compatibility checks are done between
 + * the old and new configurations.
   */
 -int config_read(int server) {
 +int config_read(int server,
 +                const struct config *oldconfig) {
    struct config *c;
    char *privconf;
    struct passwd *pw;
    }
    /* install default namepart and transform settings */
    config_postdefaults(c, server);
 +  if(oldconfig)  {
 +    int failed = 0;
 +    if(strcmp(c->home, oldconfig->home)) {
 +      error(0, "'home' cannot be changed without a restart");
 +      failed = 1;
 +    }
 +    if(strcmp(c->alias, oldconfig->alias)) {
 +      error(0, "'alias' cannot be changed without a restart");
 +      failed = 1;
 +    }
 +    if(strcmp(c->user, oldconfig->user)) {
 +      error(0, "'user' cannot be changed without a restart");
 +      failed = 1;
 +    }
 +    if(c->nice_speaker != oldconfig->nice_speaker) {
 +      error(0, "'nice_speaker' cannot be changed without a restart");
 +      /* ...but we accept the new config anyway */
 +    }
 +    if(c->nice_server != oldconfig->nice_server) {
 +      error(0, "'nice_server' cannot be changed without a restart");
 +      /* ...but we accept the new config anyway */
 +    }
 +    if(namepartlist_compare(&c->namepart, &oldconfig->namepart)) {
 +      error(0, "'namepart' settings cannot be changed without a restart");
 +      failed = 1;
 +    }
 +    if(stringlist_compare(&c->stopword, &oldconfig->stopword)) {
 +      error(0, "'stopword' settings cannot be changed without a restart");
 +      failed = 1;
 +    }
 +    if(failed) {
 +      error(0, "not installing incompatible new configuration");
 +      return -1;
 +    }
 +  }
    /* everything is good so we shall use the new config */
    config_free(config);
    /* warn about obsolete directives */
      error(0, "'allow' will be removed in a future version");
    if(c->trust.n)
      error(0, "'trust' will be removed in a future version");
 +  if(!c->lock)
 +    error(0, "'lock' will be removed in a future version");
 +  if(c->gap)
 +    error(0, "'gap' will be removed in a future version");
 +  if(c->prefsync)
 +    error(0, "'prefsync' will be removed in a future version");
    config = c;
    return 0;
  }
@@@ -1464,59 -1417,6 +1468,59 @@@ char *config_get_file(const char *name
    return config_get_file2(config, name);
  }
  
 +static int stringlist_compare(const struct stringlist *a,
 +                              const struct stringlist *b) {
 +  int n = 0, c;
 +
 +  while(n < a->n && n < b->n) {
 +    if((c = strcmp(a->s[n], b->s[n])))
 +      return c;
 +    ++n;
 +  }
 +  if(a->n < b->n)
 +    return -1;
 +  else if(a->n > b->n)
 +    return 1;
 +  else
 +    return 0;
 +}
 +
 +static int namepart_compare(const struct namepart *a,
 +                            const struct namepart *b) {
 +  int c;
 +
 +  if((c = strcmp(a->part, b->part)))
 +    return c;
 +  if((c = strcmp(a->res, b->res)))
 +    return c;
 +  if((c = strcmp(a->replace, b->replace)))
 +    return c;
 +  if((c = strcmp(a->context, b->context)))
 +    return c;
 +  if(a->reflags > b->reflags)
 +    return 1;
 +  if(a->reflags < b->reflags)
 +    return -1;
 +  return 0;
 +}
 +
 +static int namepartlist_compare(const struct namepartlist *a,
 +                                const struct namepartlist *b) {
 +  int n = 0, c;
 +
 +  while(n < a->n && n < b->n) {
 +    if((c = namepart_compare(&a->s[n], &b->s[n])))
 +      return c;
 +    ++n;
 +  }
 +  if(a->n > b->n)
 +    return 1;
 +  else if(a->n < b->n)
 +    return -1;
 +  else
 +    return 0;
 +}
 +
  /*
  Local Variables:
  c-basic-offset:2
diff --combined lib/configuration.h
index 875b9d627851d3573d05868bc8864ebfe243ed06,54fb4f8eebff6abbd7c7b5910a54c41fa2d1056d..9170a2f2b752c118e937099a1fa38e116a4015d1
@@@ -1,6 -1,7 +1,6 @@@
 -
  /*
   * This file is part of DisOrder.
 - * Copyright (C) 2004-2008 Richard Kettlewell
 + * Copyright (C) 2004-2009 Richard Kettlewell
   *
   * 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
@@@ -26,9 -27,6 +26,9 @@@
  
  #include "speaker-protocol.h"
  #include "rights.h"
 +#include "addr.h"
 +
 +struct uaudio;
  
  /* Configuration is kept in a @struct config@; the live configuration
   * is always pointed to by @config@.  Values in @config@ are UTF-8 encoded.
@@@ -70,8 -68,7 +70,8 @@@ struct collectionlist 
  
  struct namepart {
    char *part;                         /* part */
 -  pcre *re;                           /* regexp */
 +  pcre *re;                           /* compiled regexp */
 +  char *res;                            /* regexp as a string */
    char *replace;                      /* replacement string */
    char *context;                      /* context glob */
    unsigned reflags;                   /* regexp flags */
@@@ -156,7 -153,7 +156,7 @@@ struct config 
    long prefsync;                      /* preflog sync interval */
  
    /** @brief Secondary listen address */
 -  struct stringlist listen;
 +  struct netaddress listen;
  
    /** @brief Alias format string */
    const char *alias;
    /** @brief Command execute by speaker to play audio */
    const char *speaker_command;
  
 +  /** @brief Pause mode for command backend */
 +  const char *pause_mode;
 +  
    /** @brief Target sample format */
    struct stream_header sample_format;
  
    /** @brief Sox syntax generation */
    long sox_generation;
  
 -  /** @brief API used to play sound
 -   *
 -   * Choices are @ref BACKEND_ALSA, @ref BACKEND_COMMAND or @ref
 -   * BACKEND_NETWORK.
 -   */
 -  int api;
 +  /** @brief API used to play sound */
 +  const char *api;
  
 -/* These values had better be non-negative */
 -#define BACKEND_ALSA 0                        /**< Use ALSA (Linux only) */
 -#define BACKEND_COMMAND 1             /**< Execute a command */
 -#define BACKEND_NETWORK 2             /**< Transmit RTP  */
 -#define BACKEND_COREAUDIO 3           /**< Use Core Audio (Mac only) */
 -#define BACKEND_OSS 4                 /**< Use OSS */
 -
 -#if HAVE_ALSA_ASOUNDLIB_H
 -# define DEFAULT_BACKEND BACKEND_ALSA
 -#elif HAVE_SYS_SOUNDCARD_H || EMPEG_HOST
 -# define DEFAULT_BACKEND BACKEND_OSS
 -#elif HAVE_COREAUDIO_AUDIOHARDWARE_H
 -# define DEFAULT_BACKEND BACKEND_COREAUDIO
 -#else
 -# error Cannot choose a default backend
 -#endif
 -
+   /** @brief Maximum size of a playlist */
+   long playlist_max;
+   /** @brief Maximum lifetime of a playlist lock */
+   long playlist_lock_timeout;
    /** @brief Home directory for state files */
    const char *home;
  
    const char *password;
  
    /** @brief Address to connect to */
 -  struct stringlist connect;
 +  struct netaddress connect;
  
    /** @brief Directories to search for web templates */
    struct stringlist templates;
    struct transformlist transform;     /* path name transformations */
  
    /** @brief Address to send audio data to */
 -  struct stringlist broadcast;
 +  struct netaddress broadcast;
  
    /** @brief Source address for network audio transmission */
 -  struct stringlist broadcast_from;
 +  struct netaddress broadcast_from;
  
 +  /** @brief RTP delay threshold */
 +  long rtp_delay_threshold;
 +  
    /** @brief TTL for multicast packets */
    long multicast_ttl;
  
  extern struct config *config;
  /* the current configuration */
  
 -int config_read(int server);
 +int config_read(int server,
 +                const struct config *oldconfig);
  /* re-read config, return 0 on success or non-0 on error.
   * Only updates @config@ if the new configuration is valid. */
  
@@@ -317,8 -334,6 +323,8 @@@ char *config_private(void)
  extern char *configfile;
  extern int config_per_user;
  
 +extern const struct uaudio *const *config_uaudio_apis;
 +
  #endif /* CONFIGURATION_H */
  
  /*
diff --combined lib/trackdb.c
index 83a67af3f1a499f9fb28677bff087993ecc35d46,b98752f6e59a13e5b18fbbfaab1f183e3761507a..a188caaf1e96ec3c4cd81525cb486443f996c1b9
@@@ -157,6 -157,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 */
@@@ -472,7 -479,8 +479,8 @@@ 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);
 -  if(!trackdb_existing_database) {
 +  if(!trackdb_existing_database && !(flags & TRACKDB_READ_ONLY)) {
+   trackdb_playlistsdb = open_db("playlists.db", 0, DB_HASH, dbflags, 0666);
      /* Stash the database version */
      char buf[32];
  
@@@ -503,6 -511,7 +511,7 @@@ void trackdb_close(void) 
    CLOSE("noticed.db", trackdb_noticeddb);
    CLOSE("schedule.db", trackdb_scheduledb);
    CLOSE("users.db", trackdb_usersdb);
+   CLOSE("playlists.db", trackdb_playlistsdb);
    D(("closed databases"));
  }
  
@@@ -2553,8 -2562,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 python/disorder.py.in
index f6fe1a4254a85ddb2d98ac2e9f21bf32ec67f1ec,3e1541eaf97104d29ab3bcf46679fd6b4fb17b78..d06c7eef7f5773d2f205187f58dabd5b47626f1e
@@@ -50,7 -50,7 +50,7 @@@ import o
  import pwd
  import socket
  import binascii
 -import sha
 +import hashlib
  import sys
  import locale
  
@@@ -66,18 -66,6 +66,18 @@@ _unquoted = re.compile("[^\"' \\t\\n\\r
  
  _response = re.compile("([0-9]{3}) ?(.*)")
  
 +# hashes
 +_hashes = {
 +  "sha1": hashlib.sha1,
 +  "SHA1": hashlib.sha1,
 +  "sha256": hashlib.sha256,
 +  "SHA256": hashlib.sha256,
 +  "sha384": hashlib.sha384,
 +  "SHA384": hashlib.sha384,
 +  "sha512": hashlib.sha512,
 +  "SHA512": hashlib.sha512,
 +};
 +
  version = "_version_"
  
  ########################################################################
@@@ -125,8 -113,8 +125,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.
      """
@@@ -399,7 -387,8 +399,7 @@@ class client
              password = self.config['password']
            else:
              password = self.password
 -          # TODO support algorithms other than SHA-1
 -          h = sha.sha()
 +          h = _hashes[algo]()
            h.update(password)
            h.update(binascii.unhexlify(challenge))
            self._simple("user", user, h.hexdigest())
  
      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
      """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 server/dump.c
index 4b023aa0b37bff5285fb6bacfee5ac85c8c7bca7,ab48e48558586db970360f022153527794776ee8..1cee3660946fea73844d8ee2e1c3f0b1c27a606e
@@@ -29,8 -29,6 +29,6 @@@ static const struct option options[] = 
    { "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(0, "specify only a dump file name");
      path = argv[optind];
    }
 -  if(config_read(0)) fatal(0, "cannot read configuration");
 +  if(config_read(0, NULL)) fatal(0, "cannot read configuration");
    trackdb_init(recover|TRACKDB_MAY_CREATE);
    trackdb_open(TRACKDB_NO_UPGRADE);
    if(dump) {
        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
index 8bd23e74329227ea0bda09779357ab20c0f14727,320cbb174ec94c0f925d8e607773d03e6193fc8d..ce45a1dd6a968c7c116fbb061638f545a4c3fbe6
@@@ -1,6 -1,6 +1,6 @@@
  /*
   * This file is part of DisOrder.
 - * Copyright (C) 2004-2008 Richard Kettlewell
 + * Copyright (C) 2004-2009 Richard Kettlewell
   *
   * 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
   */
  
  #include "disorder-server.h"
 +#include "basen.h"
  
  #ifndef NONCE_SIZE
  # define NONCE_SIZE 16
  #endif
  
  #ifndef CONFIRM_SIZE
 -# define CONFIRM_SIZE 10
 +/** @brief Size of nonce in confirmation string in 32-bit words
 + *
 + * 64 bits gives 11 digits (in base 62).
 + */
 +# define CONFIRM_SIZE 2
  #endif
  
  int volume_left, volume_right;                /* last known volume */
@@@ -44,6 -39,34 +44,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 */
@@@ -88,6 -123,15 +128,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" };
  
@@@ -830,18 -874,17 +879,18 @@@ static int c_volume(struct conn *c
      sink_writes(ev_writer_sink(c->w), "510 Prohibited\n");
      return 1;
    }
 -  if(mixer_control(-1/*as configured*/, &l, &r, set))
 +  if(!api || !api->set_volume) {
      sink_writes(ev_writer_sink(c->w), "550 error accessing mixer\n");
 -  else {
 -    sink_printf(ev_writer_sink(c->w), "252 %d %d\n", l, r);
 -    if(l != volume_left || r != volume_right) {
 -      volume_left = l;
 -      volume_right = r;
 -      snprintf(lb, sizeof lb, "%d", l);
 -      snprintf(rb, sizeof rb, "%d", r);
 -      eventlog("volume", lb, rb, (char *)0);
 -    }
 +    return 1;
 +  }
 +  (set ? api->set_volume : api->get_volume)(&l, &r);
 +  sink_printf(ev_writer_sink(c->w), "252 %d %d\n", l, r);
 +  if(l != volume_left || r != volume_right) {
 +    volume_left = l;
 +    volume_right = r;
 +    snprintf(lb, sizeof lb, "%d", l);
 +    snprintf(rb, sizeof rb, "%d", r);
 +    eventlog("volume", lb, rb, (char *)0);
    }
    return 1;
  }
@@@ -1030,21 -1073,25 +1079,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) {
@@@ -1104,13 -1151,10 +1157,13 @@@ static int c_new(struct conn *c
  static int c_rtp_address(struct conn *c,
                         char attribute((unused)) **vec,
                         int attribute((unused)) nvec) {
 -  if(config->api == BACKEND_NETWORK) {
 +  if(api == &uaudio_rtp) {
 +    char **addr;
 +
 +    netaddress_format(&config->broadcast, NULL, &addr);
      sink_printf(ev_writer_sink(c->w), "252 %s %s\n",
 -              quoteutf8(config->broadcast.s[0]),
 -              quoteutf8(config->broadcast.s[1]));
 +              quoteutf8(addr[1]),
 +              quoteutf8(addr[2]));
    } else
      sink_writes(ev_writer_sink(c->w), "550 No RTP\n");
    return 1;
@@@ -1314,36 -1358,33 +1367,26 @@@ 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
 - *
 - * This is used with generic_to_base64() and generic_base64().  We cannot use
 - * the MIME table as that contains '+' and '=' which get quoted when
 - * URL-encoding.  (The CGI still does the URL encoding but it is desirable to
 - * avoid it being necessary.)
 - */
 -static const char confirm_base64_table[] =
 -  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/.*";
 -
  static int c_register(struct conn *c,
                      char **vec,
                      int attribute((unused)) nvec) {
 -  char *buf, *cs;
 -  size_t bufsize;
 -  int offset;
 -
 -  /* The confirmation string is base64(username;nonce) */
 -  bufsize = strlen(vec[0]) + CONFIRM_SIZE + 2;
 -  buf = xmalloc_noptr(bufsize);
 -  offset = byte_snprintf(buf, bufsize, "%s;", vec[0]);
 -  gcry_randomize(buf + offset, CONFIRM_SIZE, GCRY_STRONG_RANDOM);
 -  cs = generic_to_base64((uint8_t *)buf, offset + CONFIRM_SIZE,
 -                       confirm_base64_table);
 +  char *cs;
 +  uint32_t nonce[CONFIRM_SIZE];
 +  char nonce_str[(32 * CONFIRM_SIZE) / 5 + 1];
 +
 +  /* The confirmation string is username/base62(nonce).  The confirmation
 +   * process will pick the username back out to identify them but the _whole_
 +   * string is used as the confirmation string.  Base 62 means we used only
 +   * letters and digits, minimizing the chance of the URL being mispasted. */
 +  gcry_randomize(nonce, sizeof nonce, GCRY_STRONG_RANDOM);
 +  if(basen(nonce, CONFIRM_SIZE, nonce_str, sizeof nonce_str, 62)) {
 +    error(0, "buffer too small encoding confirmation string");
 +    sink_writes(ev_writer_sink(c->w), "550 Cannot create user\n");
 +  }
 +  byte_xasprintf(&cs, "%s/%s", vec[0], nonce_str);
    if(trackdb_adduser(vec[0], vec[1], config->default_rights, vec[2], cs))
      sink_writes(ev_writer_sink(c->w), "550 Cannot create user\n");
    else
  static int c_confirm(struct conn *c,
                     char **vec,
                     int attribute((unused)) nvec) {
 -  size_t nuser;
    char *user, *sep;
    rights_type rights;
    const char *host;
      sink_writes(ev_writer_sink(c->w), "530 Authentication failure\n");
      return 1;
    }
 -  if(!(user = generic_base64(vec[0], &nuser, confirm_base64_table))
 -     || !(sep = memchr(user, ';', nuser))) {
 +  /* Picking the LAST / means we don't (here) rule out slashes in usernames. */
 +  if(!(sep = strrchr(vec[0], '/'))) {
      sink_writes(ev_writer_sink(c->w), "550 Malformed confirmation string\n");
      return 1;
    }
 -  *sep = 0;
 +  user = xstrndup(vec[0], sep - vec[0]);
    if(trackdb_confirm(user, vec[0], &rights))
      sink_writes(ev_writer_sink(c->w), "550 Incorrect confirmation string\n");
    else {
@@@ -1599,6 -1641,152 +1642,152 @@@ static int c_adopt(struct conn *c
    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;
    { "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;
@@@ -1757,7 -1998,7 +1999,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) {
@@@ -1820,6 -2061,7 +2062,7 @@@ static int listen_callback(ev_source *e
    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",
@@@ -1850,7 -2092,6 +2093,7 @@@ int server_start(ev_source *ev, int pf
    l->pf = pf;
    if(ev_listen(ev, fd, listen_callback, l, "server listener"))
      exit(EXIT_FAILURE);
 +  info("listening on %s", name);
    return fd;
  }
  
diff --combined tests/Makefile.am
index dfe742488bf67c7f225cfe244b35ea884e61358a,be3d73b69e84219b2ae52dfd3bfe1aafd4e33720..5563f7fbeb61c590ed53df901d39033c8e73407f
@@@ -1,6 -1,6 +1,6 @@@
  #
  # This file is part of DisOrder.
 -# Copyright (C) 2004, 2005, 2007, 2008 Richard Kettlewell
 +# Copyright (C) 2004, 2005, 2007-2009 Richard Kettlewell
  #
  # 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
@@@ -26,12 -26,12 +26,12 @@@ 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 hashes.py
 -      schedule.py playlists.py
++      schedule.py hashes.py playlists.py
  
  TESTS_ENVIRONMENT=${PYTHON} -u
  
  clean-local:
        rm -rf testroot *.log *.pyc
  
 -EXTRA_DIST=dtest.py ${TESTS}
 +EXTRA_DIST=dtest.py ${TESTS} fail.py
  CLEANFILES=*.gcda *.gcov *.gcno *.c.html index.html