Merge from disorder.dev.
authorRichard Kettlewell <rjk@greenend.org.uk>
Sun, 18 Oct 2009 21:54:18 +0000 (22:54 +0100)
committerRichard Kettlewell <rjk@greenend.org.uk>
Sun, 18 Oct 2009 21:54:18 +0000 (22:54 +0100)
1  2 
disobedience/queue-generic.c
disobedience/queue.c
lib/eclient.c
lib/trackdb.c
server/server.c

@@@ -129,7 -129,7 +129,7 @@@ const char *column_length(const struct 
      else {
        if(!last_playing)
          return NULL;
-       time(&now);
+       xtime(&now);
        l = playing_track->sofar + (now - last_playing);
      }
      byte_xasprintf(&played, "%ld:%02ld/%s", l / 60, l % 60, length);
  /** @brief Return the @ref queue_entry corresponding to @p iter
   * @param model Model that owns @p iter
   * @param iter Tree iterator
 - * @return ID string
 + * @return Pointer to queue entry
   */
  struct queue_entry *ql_iter_to_q(GtkTreeModel *model,
                                   GtkTreeIter *iter) {
@@@ -402,120 -402,6 +402,120 @@@ void ql_new_queue(struct queuelike *ql
    --suppress_actions;
  }
  
 +/* Drag and drop has to be figured out experimentally, because it is not well
 + * documented.
 + *
 + * First you get a row-inserted.  The path argument points to the destination
 + * row but this will not yet have had its values set.  The source row is still
 + * present.  AFAICT the iter argument points to the same place.
 + *
 + * Then you get a row-deleted.  The path argument identifies the row that was
 + * deleted.  By this stage the row inserted above has acquired its values.
 + *
 + * A complication is that the deletion will move the inserted row.  For
 + * instance, if you do a drag that moves row 1 down to after the track that was
 + * formerly on row 9, in the row-inserted call it will show up as row 10, but
 + * in the row-deleted call, row 1 will have been deleted thus making the
 + * inserted row be row 9.
 + *
 + * So when we see the row-inserted we have no idea what track to move.
 + * Therefore we stash it until we see a row-deleted.
 + */
 +
 +/** @brief row-inserted callback */
 +static void ql_row_inserted(GtkTreeModel attribute((unused)) *treemodel,
 +                            GtkTreePath *path,
 +                            GtkTreeIter attribute((unused)) *iter,
 +                            gpointer user_data) {
 +  struct queuelike *const ql = user_data;
 +  if(!suppress_actions) {
 +#if 0
 +    char *ps = gtk_tree_path_to_string(path);
 +    GtkTreeIter piter[1];
 +    gboolean pi = gtk_tree_model_get_iter(treemodel, piter, path);
 +    struct queue_entry *pq = pi ? ql_iter_to_q(treemodel, piter) : 0;
 +    struct queue_entry *iq = ql_iter_to_q(treemodel, iter);
 +
 +    fprintf(stderr, "row-inserted %s path=%s pi=%d pq=%p path=%s iq=%p iter=%s\n",
 +            ql->name,
 +            ps,
 +            pi,
 +            pq,
 +            (pi
 +             ? (pq ? pq->track : "(pq=0)")
 +             : "(pi=FALSE)"),
 +            iq,
 +            iq ? iq->track : "(iq=0)");
 +
 +    GtkTreeIter j[1];
 +    gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
 +    int row = 0;
 +    while(jt) {
 +      struct queue_entry *q = ql_iter_to_q(treemodel, j);
 +      fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
 +      jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql->store), j);
 +    }
 +    g_free(ps);
 +#endif
 +    /* Remember an iterator pointing at the insertion target */
 +    if(ql->drag_target)
 +      gtk_tree_path_free(ql->drag_target);
 +    ql->drag_target = gtk_tree_path_copy(path);
 +  }
 +}
 +
 +/** @brief row-deleted callback */
 +static void ql_row_deleted(GtkTreeModel attribute((unused)) *treemodel,
 +                           GtkTreePath *path,
 +                           gpointer user_data) {
 +  struct queuelike *const ql = user_data;
 +
 +  if(!suppress_actions) {
 +#if 0
 +    char *ps = gtk_tree_path_to_string(path);
 +    fprintf(stderr, "row-deleted %s path=%s ql->drag_target=%s\n",
 +            ql->name, ps, gtk_tree_path_to_string(ql->drag_target));
 +    GtkTreeIter j[1];
 +    gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
 +    int row = 0;
 +    while(jt) {
 +      struct queue_entry *q = ql_iter_to_q(treemodel, j);
 +      fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
 +      jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql->store), j);
 +    }
 +    g_free(ps);
 +#endif
 +    if(!ql->drag_target) {
 +      error(0, "%s: unsuppressed row-deleted with no row-inserted",
 +            ql->name);
 +      return;
 +    }
 +
 +    /* Get the source and destination row numbers. */
 +    int srcrow = gtk_tree_path_get_indices(path)[0];
 +    int dstrow = gtk_tree_path_get_indices(ql->drag_target)[0];
 +    //fprintf(stderr, "srcrow=%d dstrow=%d\n", srcrow, dstrow);
 +
 +    /* Note that the source row is computed AFTER the destination has been
 +     * inserted, since GTK+ does the insert before the delete.  Therefore if
 +     * the source row is south (higher row number) of the destination, it will
 +     * be one higher than expected.
 +     *
 +     * For instance if we drag row 1 to before row 0 we will see row-inserted
 +     * for row 0 but then a row-deleted for row 2.
 +     */
 +    if(srcrow > dstrow)
 +      --srcrow;
 +
 +    /* Tell the queue implementation */
 +    ql->drop(srcrow, dstrow);
 +
 +    /* Dispose of stashed data */
 +    gtk_tree_path_free(ql->drag_target);
 +    ql->drag_target = 0;
 +  }
 +}
 +
  /** @brief Initialize a @ref queuelike */
  GtkWidget *init_queuelike(struct queuelike *ql) {
    D(("init_queuelike"));
    g_signal_connect(ql->view, "button-press-event",
                     G_CALLBACK(ql_button_release), ql);
  
 +  /* Drag+drop*/
 +  if(ql->drop) {
 +    gtk_tree_view_set_reorderable(GTK_TREE_VIEW(ql->view), TRUE);
 +    g_signal_connect(ql->store,
 +                     "row-inserted",
 +                     G_CALLBACK(ql_row_inserted), ql);
 +    g_signal_connect(ql->store,
 +                     "row-deleted",
 +                     G_CALLBACK(ql_row_deleted), ql);
 +  }
 +  
    /* TODO style? */
  
    ql->init();
diff --combined disobedience/queue.c
@@@ -97,7 -97,7 +97,7 @@@ static void playing_completed(void attr
    }
    actual_playing_track = q;
    queue_playing_changed();
-   time(&last_playing);
+   xtime(&last_playing);
  }
  
  /** @brief Schedule an update to the queue
@@@ -152,61 -152,6 +152,61 @@@ static void queue_init(void) 
    g_timeout_add(1000/*ms*/, playing_periodic, 0);
  }
  
 +static void queue_move_completed(void attribute((unused)) *v,
 +                                 const char *err) {
 +  if(err) {
 +    popup_protocol_error(0, err);
 +    return;
 +  }
 +  /* The log should tell us the queue changed so we do no more here */
 +}
 +
 +/** @brief Called when drag+drop completes */
 +static void queue_drop(int src, int dst) {
 +  struct queue_entry *sq, *dq;
 +  int n;
 +
 +  //fprintf(stderr, "queue_drop %d -> %d\n", src, dst);
 +  if(playing_track) {
 +    /* If there's a playing track then you can't drag it anywhere  */
 +    if(src == 0) {
 +      //fprintf(stderr, "cannot drag playing track\n");
 +      queue_playing_changed();
 +      return;
 +    }
 +    /* If you try to drop before the playing track we assume you missed and
 +     * mean after instead */
 +    if(!dst)
 +      dst = 1;
 +    //fprintf(stderr, "...adjusted to %d -> %d\n\n", src, dst);
 +  }
 +  /* Find the entry to move */
 +  for(n = 0, sq = ql_queue.q; n < src; ++n)
 +    sq = sq->next;
 +  /*fprintf(stderr, "source=%s (%s)\n",
 +          sq->id, sq->track);*/
 +  const int after = dst - 1;
 +  if(after == -1)
 +    dq = 0;
 +  else
 +    /* Find the entry to insert after */
 +    for(n = 0, dq = ql_queue.q; n < after; ++n)
 +      dq = dq->next;
 +  if(dq == playing_track)
 +    dq = 0;
 +#if 0
 +  if(dq)
 +    fprintf(stderr, "after=%s (%s)\n",
 +            dq->id, dq->track);
 +  else
 +    fprintf(stderr, "after=NULL\n");
 +#endif
 +  disorder_eclient_moveafter(client,
 +                             dq ? dq->id : "",
 +                             1, &sq->id,
 +                             queue_move_completed, NULL);
 +}
 +
  /** @brief Columns for the queue */
  static const struct queue_column queue_columns[] = {
    { "When",   column_when,     0,        COL_RIGHT },
@@@ -233,10 -178,161 +233,10 @@@ struct queuelike ql_queue = 
    .columns = queue_columns,
    .ncolumns = sizeof queue_columns / sizeof *queue_columns,
    .menuitems = queue_menuitems,
 -  .nmenuitems = sizeof queue_menuitems / sizeof *queue_menuitems
 +  .nmenuitems = sizeof queue_menuitems / sizeof *queue_menuitems,
 +  .drop = queue_drop
  };
  
 -/* Drag and drop has to be figured out experimentally, because it is not well
 - * documented.
 - *
 - * First you get a row-inserted.  The path argument points to the destination
 - * row but this will not yet have had its values set.  The source row is still
 - * present.  AFAICT the iter argument points to the same place.
 - *
 - * Then you get a row-deleted.  The path argument identifies the row that was
 - * deleted.  By this stage the row inserted above has acquired its values.
 - *
 - * A complication is that the deletion will move the inserted row.  For
 - * instance, if you do a drag that moves row 1 down to after the track that was
 - * formerly on row 9, in the row-inserted call it will show up as row 10, but
 - * in the row-deleted call, row 1 will have been deleted thus making the
 - * inserted row be row 9.
 - *
 - * So when we see the row-inserted we have no idea what track to move.
 - * Therefore we stash it until we see a row-deleted.
 - */
 -
 -/** @brief Target row for drag */
 -static int queue_drag_target = -1;
 -
 -static void queue_move_completed(void attribute((unused)) *v,
 -                                 const char *err) {
 -  if(err) {
 -    popup_protocol_error(0, err);
 -    return;
 -  }
 -  /* The log should tell us the queue changed so we do no more here */
 -}
 -
 -static void queue_row_deleted(GtkTreeModel *treemodel,
 -                              GtkTreePath *path,
 -                              gpointer attribute((unused)) user_data) {
 -  if(!suppress_actions) {
 -#if 0
 -    char *ps = gtk_tree_path_to_string(path);
 -    fprintf(stderr, "row-deleted path=%s queue_drag_target=%d\n",
 -            ps, queue_drag_target);
 -    GtkTreeIter j[1];
 -    gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
 -    int row = 0;
 -    while(jt) {
 -      struct queue_entry *q = ql_iter_to_q(treemodel, j);
 -      fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
 -      jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql_queue.store), j);
 -    }
 -    g_free(ps);
 -#endif
 -    if(queue_drag_target < 0) {
 -      error(0, "unsuppressed row-deleted with no row-inserted");
 -      return;
 -    }
 -    int drag_source = gtk_tree_path_get_indices(path)[0];
 -
 -    /* If the drag is downwards (=towards higher row numbers) then the target
 -     * will have been moved upwards (=towards lower row numbers) by one row. */
 -    if(drag_source < queue_drag_target)
 -      --queue_drag_target;
 -    
 -    /* Find the track to move */
 -    GtkTreeIter src[1];
 -    gboolean srcv = gtk_tree_model_iter_nth_child(treemodel, src, NULL,
 -                                                  queue_drag_target);
 -    if(!srcv) {
 -      error(0, "cannot get iterator to drag target %d", queue_drag_target);
 -      queue_playing_changed();
 -      queue_drag_target = -1;
 -      return;
 -    }
 -    struct queue_entry *srcq = ql_iter_to_q(treemodel, src);
 -    assert(srcq);
 -    //fprintf(stderr, "move %s %s\n", srcq->id, srcq->track);
 -    
 -    /* Don't allow the currently playing track to be moved.  As above, we put
 -     * the queue back into the right order straight away. */
 -    if(srcq == playing_track) {
 -      //fprintf(stderr, "cannot move currently playing track\n");
 -      queue_playing_changed();
 -      queue_drag_target = -1;
 -      return;
 -    }
 -
 -    /* Find the destination */
 -    struct queue_entry *dstq;
 -    if(queue_drag_target) {
 -      GtkTreeIter dst[1];
 -      gboolean dstv = gtk_tree_model_iter_nth_child(treemodel, dst, NULL,
 -                                                    queue_drag_target - 1);
 -      if(!dstv) {
 -        error(0, "cannot get iterator to drag target predecessor %d",
 -              queue_drag_target - 1);
 -        queue_playing_changed();
 -        queue_drag_target = -1;
 -        return;
 -      }
 -      dstq = ql_iter_to_q(treemodel, dst);
 -      assert(dstq);
 -      if(dstq == playing_track)
 -        dstq = 0;
 -    } else
 -      dstq = 0;
 -    /* NB if the user attempts to move a queued track before the currently
 -     * playing track we assume they just missed a bit, and put it after. */
 -    //fprintf(stderr, " target %s %s\n", dstq ? dstq->id : "(none)", dstq ? dstq->track : "(none)");
 -    /* Now we know what is to be moved.  We need to know the preceding queue
 -     * entry so we can move it. */
 -    disorder_eclient_moveafter(client,
 -                               dstq ? dstq->id : "",
 -                               1, &srcq->id,
 -                               queue_move_completed, NULL);
 -    queue_drag_target = -1;
 -  }
 -}
 -
 -static void queue_row_inserted(GtkTreeModel attribute((unused)) *treemodel,
 -                               GtkTreePath *path,
 -                               GtkTreeIter attribute((unused)) *iter,
 -                               gpointer attribute((unused)) user_data) {
 -  if(!suppress_actions) {
 -#if 0
 -    char *ps = gtk_tree_path_to_string(path);
 -    GtkTreeIter piter[1];
 -    gboolean pi = gtk_tree_model_get_iter(treemodel, piter, path);
 -    struct queue_entry *pq = pi ? ql_iter_to_q(treemodel, piter) : 0;
 -    struct queue_entry *iq = ql_iter_to_q(treemodel, iter);
 -
 -    fprintf(stderr, "row-inserted path=%s pi=%d pq=%p path=%s iq=%p iter=%s\n",
 -            ps,
 -            pi,
 -            pq,
 -            (pi
 -             ? (pq ? pq->track : "(pq=0)")
 -             : "(pi=FALSE)"),
 -            iq,
 -            iq ? iq->track : "(iq=0)");
 -
 -    GtkTreeIter j[1];
 -    gboolean jt = gtk_tree_model_get_iter_first(treemodel, j);
 -    int row = 0;
 -    while(jt) {
 -      struct queue_entry *q = ql_iter_to_q(treemodel, j);
 -      fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)");
 -      jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql_queue.store), j);
 -    }
 -    g_free(ps);
 -#endif
 -    queue_drag_target = gtk_tree_path_get_indices(path)[0];
 -  }
 -}
 -
  /** @brief Called when a key is pressed in the queue tree view */
  static gboolean queue_key_press(GtkWidget attribute((unused)) *widget,
                                  GdkEventKey *event,
  GtkWidget *queue_widget(void) {
    GtkWidget *const w = init_queuelike(&ql_queue);
  
 -  /* Enable drag+drop */
 -  gtk_tree_view_set_reorderable(GTK_TREE_VIEW(ql_queue.view), TRUE);
 -  g_signal_connect(ql_queue.store,
 -                   "row-inserted",
 -                   G_CALLBACK(queue_row_inserted), &ql_queue);
 -  g_signal_connect(ql_queue.store,
 -                   "row-deleted",
 -                   G_CALLBACK(queue_row_deleted), &ql_queue);
    /* Catch keypresses */
    g_signal_connect(ql_queue.view, "key-press-event",
                     G_CALLBACK(queue_key_press), &ql_queue);
diff --combined lib/eclient.c
@@@ -92,7 -92,6 +92,7 @@@ typedef void operation_callback(disorde
  struct operation {
    struct operation *next;          /**< @brief next operation */
    char *cmd;                       /**< @brief command to send or 0 */
 +  char **body;                     /**< @brief command body */
    operation_callback *opcallback;  /**< @brief internal completion callback */
    void (*completed)();             /**< @brief user completion callback or 0 */
    void *v;                         /**< @brief data for COMPLETED */
@@@ -166,8 -165,6 +166,8 @@@ static void stash_command(disorder_ecli
                            operation_callback *opcallback,
                            void (*completed)(),
                            void *v,
 +                          int nbody,
 +                          char **body,
                            const char *cmd,
                            ...);
  static void log_opcallback(disorder_eclient *c, struct operation *op);
@@@ -190,9 -187,6 +190,9 @@@ static void logentry_user_delete(disord
  static void logentry_user_edit(disorder_eclient *c, int nvec, char **vec);
  static void logentry_rights_changed(disorder_eclient *c, int nvec, char **vec);
  static void logentry_adopted(disorder_eclient *c, int nvec, char **vec);
 +static void logentry_playlist_created(disorder_eclient *c, int nvec, char **vec);
 +static void logentry_playlist_deleted(disorder_eclient *c, int nvec, char **vec);
 +static void logentry_playlist_modified(disorder_eclient *c, int nvec, char **vec);
  
  /* Tables ********************************************************************/
  
@@@ -214,9 -208,6 +214,9 @@@ static const struct logentry_handler lo
    LE(failed, 2, 2),
    LE(moved, 1, 1),
    LE(playing, 1, 2),
 +  LE(playlist_created, 2, 2),
 +  LE(playlist_deleted, 1, 1),
 +  LE(playlist_modified, 2, 2),
    LE(queue, 2, INT_MAX),
    LE(recent_added, 2, INT_MAX),
    LE(recent_removed, 1, 1),
@@@ -335,24 -326,6 +335,24 @@@ static int protocol_error(disorder_ecli
  
  /* State machine *************************************************************/
  
 +/** @brief Send an operation (into the output buffer)
 + * @param op Operation to send
 + */
 +static void op_send(struct operation *op) {
 +  disorder_eclient *const c = op->client;
 +  put(c, op->cmd, strlen(op->cmd));
 +  if(op->body) {
 +    for(int n = 0; op->body[n]; ++n) {
 +      if(op->body[n][0] == '.')
 +        put(c, ".", 1);
 +      put(c, op->body[n], strlen(op->body[n]));
 +      put(c, "\n", 1);
 +    }
 +    put(c, ".\n", 2);
 +  }
 +  op->sent = 1;
 +}
 +
  /** @brief Called when there's something to do
   * @param c Client
   * @param mode bitmap of @ref DISORDER_POLL_READ and/or @ref DISORDER_POLL_WRITE.
@@@ -406,7 -379,7 +406,7 @@@ void disorder_eclient_polled(disorder_e
      D(("state_connected"));
      /* We just connected.  Initiate the authentication protocol. */
      stash_command(c, 1/*queuejump*/, authbanner_opcallback,
 -                  0/*completed*/, 0/*v*/, 0/*cmd*/);
 +                  0/*completed*/, 0/*v*/, -1/*nbody*/, 0/*body*/, 0/*cmd*/);
      /* We never stay is state_connected very long.  We could in principle jump
       * straight to state_cmdresponse since there's actually no command to
       * send, but that would arguably be cheating. */
        if(c->authenticated) {
          /* Transmit all unsent operations */
          for(op = c->ops; op; op = op->next) {
 -          if(!op->sent) {
 -            put(c, op->cmd, strlen(op->cmd));
 -            op->sent = 1;
 -          }
 +          if(!op->sent)
 +            op_send(op);
          }
        } else {
          /* Just send the head operation */
 -        if(c->ops->cmd && !c->ops->sent) {
 -          put(c, c->ops->cmd, strlen(c->ops->cmd));
 -          c->ops->sent = 1;
 -        }
 +        if(c->ops->cmd && !c->ops->sent)
 +          op_send(c->ops);
        }
        /* Awaiting response for the operation at the head of the list */
        c->state = state_cmdresponse;
    /* Queue up a byte to send */
    if(c->state == state_log
       && c->output.nvec == 0
-      && time(&now) - c->last_prod > LOG_PROD_INTERVAL) {
+      && xtime(&now) - c->last_prod > LOG_PROD_INTERVAL) {
      put(c, "x", 1);
      c->last_prod = now;
    }
@@@ -624,7 -601,6 +624,7 @@@ static void authbanner_opcallback(disor
      return;
    }
    stash_command(c, 1/*queuejump*/, authuser_opcallback, 0/*completed*/, 0/*v*/,
 +                -1/*nbody*/, 0/*body*/,
                  "user", quoteutf8(config->username), quoteutf8(res),
                  (char *)0);
  }
@@@ -649,7 -625,6 +649,7 @@@ static void authuser_opcallback(disorde
    if(c->log_callbacks && !(c->ops && c->ops->opcallback == log_opcallback))
      /* We are a log client, switch to logging mode */
      stash_command(c, 0/*queuejump*/, log_opcallback, 0/*completed*/, c->log_v,
 +                  -1/*nbody*/, 0/*body*/,
                    "log", (char *)0);
  }
  
@@@ -812,8 -787,6 +812,8 @@@ static void stash_command_vector(disord
                                   operation_callback *opcallback,
                                   void (*completed)(),
                                   void *v,
 +                                 int nbody,
 +                                 char **body,
                                   int ncmd,
                                   char **cmd) {
    struct operation *op = xmalloc(sizeof *op);
      op->cmd = d.vec;
    } else
      op->cmd = 0;                        /* usually, awaiting challenge */
 +  if(nbody >= 0) {
 +    op->body = xcalloc(nbody + 1, sizeof (char *));
 +    for(n = 0; n < nbody; ++n)
 +      op->body[n] = xstrdup(body[n]);
 +    op->body[n] = 0;
 +  } else
 +    op->body = NULL;
    op->opcallback = opcallback;
    op->completed = completed;
    op->v = v;
@@@ -864,8 -830,6 +864,8 @@@ static void vstash_command(disorder_ecl
                             operation_callback *opcallback,
                             void (*completed)(),
                             void *v,
 +                           int nbody,
 +                           char **body,
                             const char *cmd, va_list ap) {
    char *arg;
    struct vector vec;
      while((arg = va_arg(ap, char *)))
        vector_append(&vec, arg);
      stash_command_vector(c, queuejump, opcallback, completed, v, 
 -                         vec.nvec, vec.vec);
 +                         nbody, body, vec.nvec, vec.vec);
    } else
 -    stash_command_vector(c, queuejump, opcallback, completed, v, 0, 0);
 +    stash_command_vector(c, queuejump, opcallback, completed, v,
 +                         nbody, body,
 +                         0, 0);
  }
  
  static void stash_command(disorder_eclient *c,
                            operation_callback *opcallback,
                            void (*completed)(),
                            void *v,
 +                          int nbody,
 +                          char **body,
                            const char *cmd,
                            ...) {
    va_list ap;
  
    va_start(ap, cmd);
 -  vstash_command(c, queuejump, opcallback, completed, v, cmd, ap);
 +  vstash_command(c, queuejump, opcallback, completed, v, nbody, body, cmd, ap);
    va_end(ap);
  }
  
@@@ -1048,8 -1008,6 +1048,8 @@@ static void list_response_opcallback(di
    D(("list_response_callback"));
    if(c->rc / 100 == 2)
      completed(op->v, NULL, c->vec.nvec, c->vec.vec);
 +  else if(c->rc == 555)
 +    completed(op->v, NULL, -1, NULL);
    else
      completed(op->v, errorstring(c), 0, 0);
  }
@@@ -1081,24 -1039,7 +1081,24 @@@ static int simple(disorder_eclient *c
    va_list ap;
  
    va_start(ap, cmd);
 -  vstash_command(c, 0/*queuejump*/, opcallback, completed, v, cmd, ap);
 +  vstash_command(c, 0/*queuejump*/, opcallback, completed, v, -1, 0, cmd, ap);
 +  va_end(ap);
 +  /* Give the state machine a kick, since we might be in state_idle */
 +  disorder_eclient_polled(c, 0);
 +  return 0;
 +}
 +
 +static int simple_body(disorder_eclient *c,
 +                       operation_callback *opcallback,
 +                       void (*completed)(),
 +                       void *v,
 +                       int nbody,
 +                       char **body,
 +                       const char *cmd, ...) {
 +  va_list ap;
 +
 +  va_start(ap, cmd);
 +  vstash_command(c, 0/*queuejump*/, opcallback, completed, v, nbody, body, cmd, ap);
    va_end(ap);
    /* Give the state machine a kick, since we might be in state_idle */
    disorder_eclient_polled(c, 0);
@@@ -1183,7 -1124,7 +1183,7 @@@ int disorder_eclient_moveafter(disorder
    for(n = 0; n < nids; ++n)
      vector_append(&vec, (char *)ids[n]);
    stash_command_vector(c, 0/*queuejump*/, no_response_opcallback, completed, v,
 -                       vec.nvec, vec.vec);
 +                       -1, 0, vec.nvec, vec.vec);
    disorder_eclient_polled(c, 0);
    return 0;
  }
@@@ -1479,123 -1420,6 +1479,123 @@@ int disorder_eclient_adopt(disorder_ecl
                  "adopt", id, (char *)0);
  }
  
 +/** @brief Get the list of playlists
 + * @param c Client
 + * @param completed Called with list of playlists
 + * @param v Passed to @p completed
 + *
 + * The playlist list is not sorted in any particular order.
 + */
 +int disorder_eclient_playlists(disorder_eclient *c,
 +                               disorder_eclient_list_response *completed,
 +                               void *v) {
 +  return simple(c, list_response_opcallback, (void (*)())completed, v,
 +                "playlists", (char *)0);
 +}
 +
 +/** @brief Delete a playlist
 + * @param c Client
 + * @param completed Called on completion
 + * @param playlist Playlist to delete
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_delete(disorder_eclient *c,
 +                                     disorder_eclient_no_response *completed,
 +                                     const char *playlist,
 +                                     void *v) {
 +  return simple(c, no_response_opcallback,  (void (*)())completed, v,
 +                "playlist-delete", playlist, (char *)0);
 +}
 +
 +/** @brief Lock a playlist
 + * @param c Client
 + * @param completed Called on completion
 + * @param playlist Playlist to lock
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_lock(disorder_eclient *c,
 +                                   disorder_eclient_no_response *completed,
 +                                   const char *playlist,
 +                                   void *v) {
 +  return simple(c, no_response_opcallback,  (void (*)())completed, v,
 +                "playlist-lock", playlist, (char *)0);
 +}
 +
 +/** @brief Unlock the locked a playlist
 + * @param c Client
 + * @param completed Called on completion
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_unlock(disorder_eclient *c,
 +                                     disorder_eclient_no_response *completed,
 +                                     void *v) {
 +  return simple(c, no_response_opcallback,  (void (*)())completed, v,
 +                "playlist-unlock", (char *)0);
 +}
 +
 +/** @brief Set a playlist's sharing
 + * @param c Client
 + * @param completed Called on completion
 + * @param playlist Playlist to modify
 + * @param sharing @c "public" or @c "private"
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_set_share(disorder_eclient *c,
 +                                        disorder_eclient_no_response *completed,
 +                                        const char *playlist,
 +                                        const char *sharing,
 +                                        void *v) {
 +  return simple(c, no_response_opcallback,  (void (*)())completed, v,
 +                "playlist-set-share", playlist, sharing, (char *)0);
 +}
 +
 +/** @brief Get a playlist's sharing
 + * @param c Client
 + * @param completed Called with sharing status
 + * @param playlist Playlist to inspect
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_get_share(disorder_eclient *c,
 +                                        disorder_eclient_string_response *completed,
 +                                        const char *playlist,
 +                                        void *v) {
 +  return simple(c, string_response_opcallback,  (void (*)())completed, v,
 +                "playlist-get-share", playlist, (char *)0);
 +}
 +
 +/** @brief Set a playlist
 + * @param c Client
 + * @param completed Called on completion
 + * @param playlist Playlist to modify
 + * @param tracks List of tracks
 + * @param ntracks Number of tracks
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_set(disorder_eclient *c,
 +                                  disorder_eclient_no_response *completed,
 +                                  const char *playlist,
 +                                  char **tracks,
 +                                  int ntracks,
 +                                  void *v) {
 +  return simple_body(c, no_response_opcallback, (void (*)())completed, v,
 +                     ntracks, tracks,
 +                     "playlist-set", playlist, (char *)0);
 +}
 +
 +/** @brief Get a playlist's contents
 + * @param c Client
 + * @param completed Called with playlist contents
 + * @param playlist Playlist to inspect
 + * @param v Passed to @p completed
 + */
 +int disorder_eclient_playlist_get(disorder_eclient *c,
 +                                  disorder_eclient_list_response *completed,
 +                                  const char *playlist,
 +                                  void *v) {
 +  return simple(c, list_response_opcallback,  (void (*)())completed, v,
 +                "playlist-get", playlist, (char *)0);
 +}
 +
  /* Log clients ***************************************************************/
  
  /** @brief Monitor the server log
@@@ -1620,7 -1444,7 +1620,7 @@@ int disorder_eclient_log(disorder_eclie
    if(c->log_callbacks->state)
      c->log_callbacks->state(c->log_v, c->statebits);
    stash_command(c, 0/*queuejump*/, log_opcallback, 0/*completed*/, v,
 -                "log", (char *)0);
 +                -1, 0, "log", (char *)0);
    disorder_eclient_polled(c, 0);
    return 0;
  }
@@@ -1788,27 -1612,6 +1788,27 @@@ static void logentry_rights_changed(dis
    }
  }
  
 +static void logentry_playlist_created(disorder_eclient *c,
 +                                      int attribute((unused)) nvec,
 +                                      char **vec) {
 +  if(c->log_callbacks->playlist_created)
 +    c->log_callbacks->playlist_created(c->log_v, vec[0], vec[1]);
 +}
 +
 +static void logentry_playlist_deleted(disorder_eclient *c,
 +                                      int attribute((unused)) nvec,
 +                                      char **vec) {
 +  if(c->log_callbacks->playlist_deleted)
 +    c->log_callbacks->playlist_deleted(c->log_v, vec[0]);
 +}
 +
 +static void logentry_playlist_modified(disorder_eclient *c,
 +                                      int attribute((unused)) nvec,
 +                                      char **vec) {
 +  if(c->log_callbacks->playlist_modified)
 +    c->log_callbacks->playlist_modified(c->log_v, vec[0], vec[1]);
 +}
 +
  static const struct {
    unsigned long bit;
    const char *enable;
diff --combined lib/trackdb.c
@@@ -157,13 -157,6 +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 */
@@@ -479,7 -472,6 +479,7 @@@ void trackdb_open(int flags) 
    trackdb_noticeddb = open_db("noticed.db",
                               DB_DUPSORT, DB_BTREE, dbflags, 0666);
    trackdb_scheduledb = open_db("schedule.db", 0, DB_HASH, dbflags, 0666);
 +  trackdb_playlistsdb = open_db("playlists.db", 0, DB_HASH, dbflags, 0666);
    if(!trackdb_existing_database && !(flags & TRACKDB_READ_ONLY)) {
      /* Stash the database version */
      char buf[32];
@@@ -511,7 -503,6 +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"));
  }
  
@@@ -1051,7 -1042,7 +1051,7 @@@ int trackdb_notice_tid(const char *trac
    /* this is a real track */
    t_changed += kvp_set(&t, "_alias_for", 0);
    t_changed += kvp_set(&t, "_path", path);
-   time(&now);
+   xtime(&now);
    if(ret == DB_NOTFOUND) {
      /* It's a new track; record the time */
      byte_xasprintf(&noticed, "%lld", (long long)now);
@@@ -2562,10 -2553,8 +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) {
@@@ -2609,7 -2598,7 +2609,7 @@@ static int create_user(const char *user
      kvp_set(&k, "email", email);
    if(confirmation)
      kvp_set(&k, "confirmation", confirmation);
-   snprintf(s, sizeof s, "%jd", (intmax_t)time(0));
+   snprintf(s, sizeof s, "%jd", (intmax_t)xtime(0));
    kvp_set(&k, "created", s);
    return trackdb_putdata(trackdb_usersdb, user, k, tid, flags);
  }
diff --combined server/server.c
@@@ -44,34 -44,6 +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 */
@@@ -128,15 -88,6 +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" };
  
@@@ -590,13 -541,13 +590,13 @@@ static int c_queue(struct conn *c
        queue_fix_sofar(playing);
        if((l = trackdb_get(playing->track, "_length"))
         && (length = atol(l))) {
-       time(&when);
+       xtime(&when);
        when += length - playing->sofar + config->gap;
        }
      } else
        /* Nothing is playing but playing is enabled, so whatever is
         * first in the queue can be expected to start immediately. */
-       time(&when);
+       xtime(&when);
    }
    for(q = qhead.next; q != &qhead; q = q->next) {
      /* fill in estimated start time */
@@@ -943,7 -894,7 +943,7 @@@ static void logclient(const char *msg, 
        return;
    }
    sink_printf(ev_writer_sink(c->w), "%"PRIxMAX" %s\n",
-             (uintmax_t)time(0), msg);
+             (uintmax_t)xtime(0), msg);
  }
  
  static int c_log(struct conn *c,
  
    sink_writes(ev_writer_sink(c->w), "254 OK\n");
    /* pump out initial state */
-   time(&now);
+   xtime(&now);
    sink_printf(ev_writer_sink(c->w), "%"PRIxMAX" state %s\n",
              (uintmax_t)now, 
              playing_is_enabled() ? "enable_play" : "disable_play");
@@@ -1079,25 -1030,21 +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) {
@@@ -1315,7 -1262,7 +1315,7 @@@ static int c_edituser(struct conn *c
              if(d->lo)
                sink_printf(ev_writer_sink(d->w),
                            "%"PRIxMAX" rights_changed %s\n",
-                           (uintmax_t)time(0),
+                           (uintmax_t)xtime(0),
                            quoteutf8(new_rights));
            }
          }
@@@ -1367,7 -1314,17 +1367,7 @@@ static int c_userinfo(struct conn *c
  static int c_users(struct conn *c,
                   char attribute((unused)) **vec,
                   int attribute((unused)) nvec) {
 -  /* TODO de-dupe with c_tags */
 -  char **users = trackdb_listusers();
 -
 -  sink_writes(ev_writer_sink(c->w), "253 User list follows\n");
 -  while(*users) {
 -    sink_printf(ev_writer_sink(c->w), "%s%s\n",
 -              **users == '.' ? "." : "", *users);
 -    ++users;
 -  }
 -  sink_writes(ev_writer_sink(c->w), ".\n");
 -  return 1;                           /* completed */
 +  return list_response(c, "User list follows", trackdb_listusers());
  }
  
  static int c_register(struct conn *c,
@@@ -1486,7 -1443,7 +1486,7 @@@ static int c_reminder(struct conn *c
    if(!last_reminder)
      last_reminder = hash_new(sizeof (time_t));
    last = hash_find(last_reminder, vec[0]);
-   time(&now);
+   xtime(&now);
    if(last && now < *last + config->reminder_interval) {
      error(0, "sent a password reminder to '%s' too recently", vec[0]);
      sink_writes(ev_writer_sink(c->w), "550 Cannot send a reminder email\n");
@@@ -1642,152 -1599,6 +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;
@@@ -1999,7 -1757,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) {
@@@ -2062,7 -1820,6 +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",