chiark / gitweb /
Merge from 3.0 fixes branch
authorRichard Kettlewell <rjk@greenend.org.uk>
Sun, 20 Apr 2008 19:17:45 +0000 (20:17 +0100)
committerRichard Kettlewell <rjk@greenend.org.uk>
Sun, 20 Apr 2008 19:17:45 +0000 (20:17 +0100)
49 files changed:
.bzrignore
CHANGES
README.client
clients/disorder.c
configure.ac
disobedience/Makefile.am
disobedience/disobedience.h
disobedience/login.c
disobedience/menu.c
disobedience/misc.c
disobedience/properties.c
disobedience/settings.c
disobedience/users.c [new file with mode: 0644]
doc/disobedience.1.in
doc/disorder.1.in
doc/disorder.3
doc/disorder_config.5.in
doc/disorder_protocol.5.in
lib/Makefile.am
lib/bits.c [new file with mode: 0644]
lib/bits.h [new file with mode: 0644]
lib/configuration.c
lib/configuration.h
lib/eclient.c
lib/eclient.h
lib/event.c
lib/t-bits.c [new file with mode: 0644]
lib/test.c
lib/test.h
lib/trackdb-int.h
lib/trackdb.c
lib/trackdb.h
scripts/dist
scripts/htmlman
server/Makefile.am
server/api-server.c
server/choose.c [new file with mode: 0644]
server/dcgi.c
server/disorderd.c
server/play.c
server/play.h
server/rescan.c
server/server.c
templates/help.html
templates/options.labels
templates/prefs.html
tests/dtest.py
tests/queue.py
tests/user.py

index 5c8238b0a73d2cfcbfc06a52b4aa8b842cbeef33..e4a97a8c7440969e28d3db446214a11d949c2dd9 100644 (file)
@@ -146,3 +146,4 @@ examples/disorder.rc
 scripts/teardown
 sounds/long.ogg
 sounds/slap.raw
+server/disorder-choose
diff --git a/CHANGES b/CHANGES
index aff856b89737b461c8e793f7a5380d1101ddb62c..91ab4e1f26f3546b61e43dec5917406d44299719 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -1,3 +1,26 @@
+* Changes up to version 3.1
+
+** Server
+
+The 'gap' directive will no longer work.  It could be restored if there
+is real demand.
+
+*** Random Track Choice
+
+This has been completely rewritten to support new features:
+   - tracks in the recently-played list or in the queue are no longer
+     eligible for random choice
+   - there is a new 'weight' track preference allowing for non-uniform
+     track selection.  See disorder(1) for details.
+   - there is a new configuration item replay_min defining the minimum
+     time before a played track can be picked at random.  The default is
+     8 hours (which matches the earlier behaviour).
+
+** Disobedience
+
+There is now a new user management window.  From here you can add and
+remove users or modify their settings.
+
 * Changes up to version 3.0.2
 
 Builds --without-server should work again.
index cec4e8c42113dd69749cfe588fcbdc00732364af..04bdee7bddc8b0f1fd297b6259f3af8844831f14 100644 (file)
@@ -17,7 +17,7 @@ There is currently no standard DisOrder port number.
 
     where PORT is the same as 'listen PORT' on the server.
 
- 3. Copy the password file for each user to ~USER/.disorder/passwd, the
+ 3. Copy the password file for each user to ~USERNAME/.disorder/passwd, the
     contents being:
 
       password PASSWORD
index fa42558dd59ed838b6c5050a6fbfa9eeb81a621c..2088bc4a9fed9224dfd605008b79439d7a4ea160 100644 (file)
@@ -475,22 +475,22 @@ static const struct command {
   int (*isarg)(const char *);
   const char *argstr, *desc;
 } commands[] = {
-  { "adduser",        2, 3, cf_adduser, isarg_rights, "USER PASSWORD [RIGHTS]",
+  { "adduser",        2, 3, cf_adduser, isarg_rights, "USERNAME PASSWORD [RIGHTS]",
                       "Create a new user" },
   { "allfiles",       1, 2, cf_allfiles, isarg_regexp, "DIR [~REGEXP]",
                       "List all files and directories in DIR" },
-  { "authorize",      1, 2, cf_authorize, isarg_rights, "USER [RIGHTS]",
-                      "Authorize USER to connect to the server" },
-  { "deluser",        1, 1, cf_deluser, 0, "USER",
-                      "Delete a user" },
+  { "authorize",      1, 2, cf_authorize, isarg_rights, "USERNAME [RIGHTS]",
+                      "Authorize user USERNAME to connect to the server" },
+  { "deluser",        1, 1, cf_deluser, 0, "USERNAME",
+                      "Delete user USERNAME" },
   { "dirs",           1, 2, cf_dirs, isarg_regexp, "DIR [~REGEXP]",
                       "List directories in DIR" },
   { "disable",        0, 0, cf_disable, 0, "",
                       "Disable play" },
   { "disable-random", 0, 0, cf_random_disable, 0, "",
                       "Disable random play" },
-  { "edituser",       3, 3, cf_edituser, 0, "USER PROPERTY VALUE",
-                      "Set a property of a user" },
+  { "edituser",       3, 3, cf_edituser, 0, "USERNAME PROPERTY VALUE",
+                      "Set a property of user USERNAME" },
   { "enable",         0, 0, cf_enable, 0, "",
                       "Enable play" },
   { "enable-random",  0, 0, cf_random_enable, 0, "",
@@ -566,8 +566,8 @@ static const struct command {
                       "Unset a preference" },
   { "unset-global",   1, 1, cf_unset_global, 0, "NAME",
                       "Unset a global preference" },
-  { "userinfo",       2, 2, cf_userinfo, 0, "USER PROPERTY",
-                      "Get a property of as user" },
+  { "userinfo",       2, 2, cf_userinfo, 0, "USERNAME PROPERTY",
+                      "Get a property of a user" },
   { "users",          0, 0, cf_users, 0, "",
                       "List all users" },
   { "version",        0, 0, cf_version, 0, "",
index cd663d1d6b602391fdd726fcf46f9c27f60486f9..1de3613ff903daa6601b4a0294fa0b0a51ab4d5e 100644 (file)
@@ -1,3 +1,4 @@
+
 # Process this file with autoconf to produce a configure script.
 #
 # This file is part of DisOrder.
@@ -20,9 +21,9 @@
 # USA
 #
 
-AC_INIT([disorder], [3.0+fixes], [richard+disorder@sfere.greenend.org.uk])
+AC_INIT([disorder], [3.0+], [richard+disorder@sfere.greenend.org.uk])
 AC_CONFIG_AUX_DIR([config.aux])
-AM_INIT_AUTOMAKE(disorder, [3.0+fixes])
+AM_INIT_AUTOMAKE(disorder, [3.0+])
 AC_CONFIG_SRCDIR([server/disorderd.c])
 AM_CONFIG_HEADER([config.h])
 
@@ -427,6 +428,10 @@ AC_CHECK_FUNCS([fdatasync],[:],[
 if test ! -z "$missing_functions"; then
   AC_MSG_ERROR([missing functions:$missing_functions])
 fi
+
+# Functions we can take or leave
+AC_CHECK_FUNCS([fls])
+
 if test $want_server = yes; then
   # <db.h> had better be version 3 or later
   AC_CACHE_CHECK([db.h version],[rjk_cv_db_version],[
index 031d68d7ed29c651022600f2e1cc505cf0bb0fff..0b1433b1124fa43d5c2eee3ebae1b50fb3c0953f 100644 (file)
@@ -28,7 +28,7 @@ PNGS:=$(shell export LC_COLLATE=C;echo ${top_srcdir}/images/*.png)
 disobedience_SOURCES=disobedience.h disobedience.c client.c queue.c    \
                  choose.c misc.c control.c properties.c menu.c \
                  log.c progress.c login.c rtp.c help.c \
-                 ../lib/memgc.c settings.c
+                 ../lib/memgc.c settings.c users.c
 disobedience_LDADD=../lib/libdisorder.a $(LIBPCRE) $(LIBGC) $(LIBGCRYPT) \
        $(LIBASOUND) $(COREAUDIO) $(LIBDB)
 disobedience_LDFLAGS=$(GTK_LIBS)
index a9c801cf1f8eabefeaaf7c5d0b5dd50574726471..ccb9acc3d7258b4df8fe3c8e8d40284343aa98db 100644 (file)
@@ -75,6 +75,10 @@ struct callbackdata {
     struct choosenode *choosenode;      /* gtkchoose.c got_files/got_dirs */
     struct queuelike *ql;               /* gtkqueue.c queuelike_completed */
     struct prefdata *f;                 /* properties.c */
+    const char *user;                   /* users.c */
+    struct {
+      const char *user, *email;         /* users.c */
+    } edituser;
   } u;
 };
 
@@ -97,6 +101,7 @@ struct button {
   const gchar *stock;
   void (*clicked)(GtkButton *button, gpointer userdata);
   const char *tip;
+  GtkWidget *widget;
 };
 
 /* Variables --------------------------------------------------------------- */
@@ -138,12 +143,14 @@ void properties_reset(void);
 GtkWidget *scroll_widget(GtkWidget *child);
 /* Wrap a widget up for scrolling */
 
+GtkWidget *frame_widget(GtkWidget *w, const char *title);
+
 GdkPixbuf *find_image(const char *name);
 /* Get the pixbuf for an image.  Returns a null pointer if it cannot be
  * found. */
 
 void popup_msg(GtkMessageType mt, const char *msg);
-/* Pop up a message */
+void popup_submsg(GtkWidget *parent, GtkMessageType mt, const char *msg);
 
 void fpopup_msg(GtkMessageType mt, const char *fmt, ...);
 
@@ -157,8 +164,11 @@ void progress_window_progress(struct progress_window *pw,
 
 GtkWidget *iconbutton(const char *path, const char *tip);
 
-GtkWidget *create_buttons(const struct button *buttons,
+GtkWidget *create_buttons(struct button *buttons,
                           size_t nbuttons);
+GtkWidget *create_buttons_box(struct button *buttons,
+                              size_t nbuttons,
+                              GtkWidget *box);
 
 void register_monitor(monitor_callback *callback,
                       void *u,
@@ -185,6 +195,7 @@ void menu_update(int page);
 /* Called whenever the main menu might need to change.  PAGE is the current
  * page if known or -1 otherwise. */
 
+void users_set_sensitive(int sensitive);
 
 /* Controls */
 
@@ -242,6 +253,10 @@ void login_box(void);
 
 GtkWidget *login_window;
 
+/* User management */
+
+void manage_users(void);
+
 /* Help */
 
 void popup_help(void);
index edefe29cd36b329b311333b4effdfaeaf7635656..18afce461642b353c49cc6fc6a9bfe06ddd0ca44 100644 (file)
@@ -170,16 +170,18 @@ static void login_cancel(GtkButton attribute((unused)) *button,
 }
 
 /* Buttons that appear at the bottom of the window */
-static const struct button buttons[] = {
+static struct button buttons[] = {
   {
     "Login",
     login_ok,
     "(Re-)connect using these settings",
+    0
   },
   {
     GTK_STOCK_CLOSE,
     login_cancel,
-    "Discard changes and close window"
+    "Discard changes and close window",
+    0
   },
 };
 
@@ -232,7 +234,7 @@ void login_box(void) {
                      TRUE/*expand*/, TRUE/*fill*/, 1/*padding*/);
   gtk_box_pack_start(GTK_BOX(vbox), buttonbox,
                      FALSE/*expand*/, FALSE/*fill*/, 1/*padding*/);
-  gtk_container_add(GTK_CONTAINER(login_window), vbox);
+  gtk_container_add(GTK_CONTAINER(login_window), frame_widget(vbox, NULL));
   gtk_window_set_transient_for(GTK_WINDOW(login_window),
                                GTK_WINDOW(toplevel));
   gtk_widget_show_all(login_window);
index f93e2f7a2dfce973d509cba04780c41435167b86..f24569af7d481cf115f802dac57a0522253be41f 100644 (file)
@@ -94,6 +94,13 @@ static void login(gpointer attribute((unused)) callback_data,
   login_box();
 }
 
+/** @brief Called when the login option is activated */
+static void users(gpointer attribute((unused)) callback_data,
+                  guint attribute((unused)) callback_action,
+                  GtkWidget attribute((unused)) *menu_item) {
+  manage_users();
+}
+
 #if 0
 /** @brief Called when the settings option is activated */
 static void settings(gpointer attribute((unused)) callback_data,
@@ -123,6 +130,7 @@ void menu_update(int page) {
                            t->selectall_sensitive(tab));
   gtk_widget_set_sensitive(selectnone_widget,
                            t->selectnone_sensitive(tab));
+  /* TODO Users should only be sensitive if have RIGHT_ADMIN */
 }
    
 /** @brief Fetch version in order to display the about... popup */
@@ -207,13 +215,36 @@ static void about_popup_got_version(void attribute((unused)) *v,
   gtk_widget_destroy(w);
 }
 
+/** @brief Set 'Manage Users' menu item sensitivity */
+void users_set_sensitive(int sensitive) {
+  GtkWidget *w = gtk_item_factory_get_widget(mainmenufactory,
+                                             "<GdisorderMain>/Server/Manage users");
+  gtk_widget_set_sensitive(w, sensitive);
+}
+
+/** @brief Called with current user's rights string */
+static void menu_got_rights(void attribute((unused)) *v, const char *value) {
+  rights_type r;
+
+  if(parse_rights(value, &r, 0))
+    r = 0;
+  users_set_sensitive(!!(r & RIGHT_ADMIN));
+}
+
+/** @brief Called when we need to reset state */
+static void menu_reset(void) {
+  users_set_sensitive(0);               /* until we know better */
+  disorder_eclient_userinfo(client, menu_got_rights, config->username, "rights",
+                            0);
+}
+
 /** @brief Create the menu bar widget */
 GtkWidget *menubar(GtkWidget *w) {
   GtkWidget *m;
 
   static const GtkItemFactoryEntry entries[] = {
     {
-      (char *)"/File",                  /* path */
+      (char *)"/Server",                /* path */
       0,                                /* accelerator */
       0,                                /* callback */
       0,                                /* callback_action */
@@ -221,16 +252,24 @@ GtkWidget *menubar(GtkWidget *w) {
       0                                 /* extra_data */
     },
     { 
-      (char *)"/File/Login",            /* path */
+      (char *)"/Server/Login",          /* path */
       (char *)"<CTRL>L",                /* accelerator */
       login,                            /* callback */
       0,                                /* callback_action */
       0,                                /* item_type */
       0                                 /* extra_data */
     },
+    { 
+      (char *)"/Server/Manage users",   /* path */
+      0,                                /* accelerator */
+      users,                            /* callback */
+      0,                                /* callback_action */
+      0,                                /* item_type */
+      0                                 /* extra_data */
+    },
 #if 0
     {
-      (char *)"/File/Settings",         /* path */
+      (char *)"/Server/Settings",       /* path */
       0,                                /* accelerator */
       settings,                         /* callback */
       0,                                /* callback_action */
@@ -239,7 +278,7 @@ GtkWidget *menubar(GtkWidget *w) {
     },
 #endif
     {
-      (char *)"/File/Quit Disobedience", /* path */
+      (char *)"/Server/Quit Disobedience", /* path */
       (char *)"<CTRL>Q",                /* accelerator */
       quit_program,                     /* callback */
       0,                                /* callback_action */
@@ -367,6 +406,8 @@ GtkWidget *menubar(GtkWidget *w) {
   assert(selectall_widget != 0);
   assert(selectnone_widget != 0);
   assert(properties_widget != 0);
+  register_reset(menu_reset);
+  menu_reset();
   m = gtk_item_factory_get_widget(mainmenufactory,
                                   "<GdisorderMain>");
   set_tool_colors(m);
index db1f6ab9b891b6895c72627679dc4b2d7fc24f09..1e5ee22c3c720ed965ef50105e5f2ebe707c05b4 100644 (file)
@@ -51,7 +51,8 @@ GtkWidget *scroll_widget(GtkWidget *child) {
   gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller),
                                  GTK_POLICY_AUTOMATIC,
                                  GTK_POLICY_AUTOMATIC);
-  if(GTK_IS_LAYOUT(child)) {
+  if(GTK_IS_LAYOUT(child)
+     || GTK_IS_TREE_VIEW(child)) {
     /* Child widget has native scroll support */
     gtk_container_add(GTK_CONTAINER(scroller), child);
     /* Fix up the step increments if they are 0 (seems like an odd default?) */
@@ -72,6 +73,24 @@ GtkWidget *scroll_widget(GtkWidget *child) {
   return scroller;
 }
 
+/** @brief Put a frame round a widget
+ * @param w Widget
+ * @param label Label or NULL
+ * @return Frame widget
+ */
+GtkWidget *frame_widget(GtkWidget *w, const char *label) {
+  GtkWidget *const frame = gtk_frame_new(label);
+  GtkWidget *const hbox = gtk_hbox_new(FALSE, 0);
+  GtkWidget *const vbox = gtk_vbox_new(FALSE, 0);
+  /* We want 4 pixels outside the frame boundary... */
+  gtk_container_set_border_width(GTK_CONTAINER(frame), 4);
+  /* ...and 4 pixels inside */
+  gtk_box_pack_start(GTK_BOX(hbox), w, TRUE/*expand*/, TRUE/*fill*/, 4);
+  gtk_box_pack_start(GTK_BOX(vbox), hbox, TRUE/*expand*/, TRUE/*fill*/, 4);
+  gtk_container_add(GTK_CONTAINER(frame), vbox);
+  return frame;
+}
+
 /** @brief Find an image
  * @param name Relative path to image
  * @return pixbuf containing image
@@ -112,9 +131,13 @@ GdkPixbuf *find_image(const char *name) {
 
 /** @brief Pop up a message */
 void popup_msg(GtkMessageType mt, const char *msg) {
+  popup_submsg(toplevel, mt, msg);
+}
+
+void popup_submsg(GtkWidget *parent, GtkMessageType mt, const char *msg) {
   GtkWidget *w;
 
-  w = gtk_message_dialog_new(GTK_WINDOW(toplevel),
+  w = gtk_message_dialog_new(GTK_WINDOW(parent),
                              GTK_DIALOG_MODAL|GTK_DIALOG_DESTROY_WITH_PARENT,
                              mt,
                              GTK_BUTTONS_CLOSE,
@@ -161,23 +184,32 @@ GtkWidget *iconbutton(const char *path, const char *tip) {
   return button;
 }
 
-/** @brief Create buttons and pack them into an hbox */
-GtkWidget *create_buttons(const struct button *buttons,
-                          size_t nbuttons) {
+/** @brief Create buttons and pack them into a box, which is returned */
+GtkWidget *create_buttons_box(struct button *buttons,
+                              size_t nbuttons,
+                              GtkWidget *box) {
   size_t n;
-  GtkWidget *const hbox = gtk_hbox_new(FALSE, 1);
 
   for(n = 0; n < nbuttons; ++n) {
-    GtkWidget *const button = gtk_button_new_from_stock(buttons[n].stock);
-    gtk_widget_set_style(button, tool_style);
-    g_signal_connect(G_OBJECT(button), "clicked",
+    buttons[n].widget = gtk_button_new_from_stock(buttons[n].stock);
+    gtk_widget_set_style(buttons[n].widget, tool_style);
+    g_signal_connect(G_OBJECT(buttons[n].widget), "clicked",
                      G_CALLBACK(buttons[n].clicked), 0);
-    gtk_box_pack_start(GTK_BOX(hbox), button, FALSE, FALSE, 1);
-    gtk_tooltips_set_tip(tips, button, buttons[n].tip, "");
+    gtk_box_pack_start(GTK_BOX(box), buttons[n].widget, FALSE, FALSE, 1);
+    gtk_tooltips_set_tip(tips, buttons[n].widget, buttons[n].tip, "");
   }
-  return hbox;
+  return box;
 }
 
+/** @brief Create buttons and pack them into an hbox */
+GtkWidget *create_buttons(struct button *buttons,
+                          size_t nbuttons) {
+  return create_buttons_box(buttons, nbuttons,
+                            gtk_hbox_new(FALSE, 1));
+}
+
+
+
 /*
 Local Variables:
 c-basic-offset:2
index 7ec9042a31e5e5a72c55e0e9db25f1cb778121f0..96d5c489ddb615bad734ea5860962984f869b265 100644 (file)
@@ -121,27 +121,31 @@ static const struct pref {
   { "Album", "album", 0, &preftype_namepart },
   { "Title", "title", 0, &preftype_namepart },
   { "Tags", "tags", "", &preftype_string },
+  { "Weight", "weight", "90000", &preftype_string },
   { "Random", "pick_at_random", "1", &preftype_boolean },
 };
 
 #define NPREFS (int)(sizeof prefs / sizeof *prefs)
 
 /* Buttons that appear at the bottom of the window */
-static const struct button buttons[] = {
+static struct button buttons[] = {
   {
     GTK_STOCK_OK,
     properties_ok,
-    "Apply all changes and close window"
+    "Apply all changes and close window",
+    0
   },
   {
     GTK_STOCK_APPLY,
     properties_apply,
-    "Apply all changes and keep window open"
+    "Apply all changes and keep window open",
+    0
   },
   {
     GTK_STOCK_CANCEL,
     properties_cancel,
-    "Discard all changes and close window"
+    "Discard all changes and close window",
+    0
   },
 };
 
@@ -267,7 +271,7 @@ void properties(int ntracks, const char **tracks) {
                      scroll_widget(properties_table),
                      TRUE, TRUE, 1);
   gtk_box_pack_start(GTK_BOX(vbox), buttonbox, FALSE, FALSE, 1);
-  gtk_container_add(GTK_CONTAINER(properties_window), vbox);
+  gtk_container_add(GTK_CONTAINER(properties_window), frame_widget(vbox, NULL));
   /* The table only really wants to be vertically scrollable */
   gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(GTK_WIDGET(properties_table)->parent->parent),
                                  GTK_POLICY_NEVER,
index ae8990e3d95c4e26907e757f1b2231eeda592b6b..3cc418011a962ef02528ecc94578f0abae2a3a44 100644 (file)
@@ -338,7 +338,7 @@ void popup_settings(void) {
                        1, 1);
     }
   }
-  gtk_container_add(GTK_CONTAINER(settings_window), table);
+  gtk_container_add(GTK_CONTAINER(settings_window), frame_widget(table, NULL));
   gtk_widget_show_all(settings_window);
   /* TODO: save settings
      TODO: web browser
diff --git a/disobedience/users.c b/disobedience/users.c
new file mode 100644 (file)
index 0000000..fe90433
--- /dev/null
@@ -0,0 +1,679 @@
+/*
+ * This file is part of DisOrder
+ * Copyright (C) 2008 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
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+ * USA
+ */
+/** @file disobedience/users.c
+ * @brief User management for Disobedience
+ *
+ * The user management window contains:
+ * - a list of all the users
+ * - an add button
+ * - a delete button
+ * - a user details panel
+ * - an apply button
+ *
+ * When you select a user that user's details are displayed to the right of the
+ * list.  Hit the Apply button and any changes are applied.
+ *
+ * When you select 'add' a new empty set of details are displayed to be edited.
+ * Again Apply will commit them.
+ *
+ * TODO: it would be really nice if the Username entry could be removed and new
+ * user names entered in the list, rather off in the details panel.  This may
+ * be possible with a sufficiently clever GtkCellRenderer.
+ */
+
+#include "disobedience.h"
+#include "bits.h"
+
+static GtkWidget *users_window;
+static GtkListStore *users_list;
+static GtkTreeSelection *users_selection;
+
+static GtkWidget *users_details_table;
+static GtkWidget *users_apply_button;
+static GtkWidget *users_delete_button;
+static GtkWidget *users_details_name;
+static GtkWidget *users_details_email;
+static GtkWidget *users_details_password;
+static GtkWidget *users_details_password2;
+static GtkWidget *users_details_rights[32];
+static int users_details_row;
+static const char *users_selected;
+static const char *users_deferred_select;
+
+static int users_mode;
+#define MODE_NONE 0
+#define MODE_ADD 1
+#define MODE_EDIT 2
+
+#define mode(X) do {                                    \
+  users_mode = MODE_##X;                                \
+  if(0) fprintf(stderr, "%s:%d: %s(): mode -> %s\n",    \
+          __FILE__, __LINE__, __FUNCTION__, #X);        \
+  users_details_sensitize_all();                        \
+} while(0)
+
+static const char *users_email, *users_rights, *users_password;
+
+/** @brief qsort() callback for username comparison */
+static int usercmp(const void *a, const void *b) {
+  return strcmp(*(char **)a, *(char **)b);
+}
+
+/** @brief Find a user
+ * @param user User to find
+ * @param iter Iterator to point at user
+ * @return 0 on success, -1 if not found
+ */
+static int users_find_user(const char *user,
+                           GtkTreeIter *iter) {
+  char *who;
+
+  /* Find the user */
+  if(!gtk_tree_model_get_iter_first(GTK_TREE_MODEL(users_list), iter))
+    return -1;
+  do {
+    gtk_tree_model_get(GTK_TREE_MODEL(users_list), iter,
+                      0, &who, -1);
+    if(!strcmp(who, user)) {
+      g_free(who);
+      return 0;
+    }
+    g_free(who);
+  } while(gtk_tree_model_iter_next(GTK_TREE_MODEL(users_list), iter));
+  return -1;
+}
+
+/** @brief Called with the list of users
+ *
+ * Called:
+ * - at startup to populate the initial list
+ * - when we add a user
+ * - maybe in the future when we delete a user
+ *
+ * If users_deferred_select is set then that user is selected.
+ */
+static void users_got_list(void attribute((unused)) *v, int nvec, char **vec) {
+  int n;
+  GtkTreeIter iter;
+
+  /* Present users in alphabetical order */
+  qsort(vec, nvec, sizeof (char *), usercmp);
+  /* Set the list contents */
+  gtk_list_store_clear(users_list);
+  for(n = 0; n < nvec; ++n)
+    gtk_list_store_insert_with_values(users_list, &iter, n/*position*/,
+                                     0, vec[n], /* column 0 */
+                                     -1);       /* no more columns */
+  /* Only show the window when the list is populated */
+  gtk_widget_show_all(users_window);
+  if(users_deferred_select) {
+    if(!users_find_user(users_deferred_select, &iter))
+      gtk_tree_selection_select_iter(users_selection, &iter);
+    users_deferred_select = 0;
+  }
+}
+
+/** @brief Text should be visible */
+#define DETAIL_VISIBLE 1
+
+/** @brief Text should be editable */
+#define DETAIL_EDITABLE 2
+
+/** @brief Add a row to the user detail table */
+static void users_detail_generic(const char *title,
+                                 GtkWidget *selector) {
+  const int row = users_details_row++;
+  GtkWidget *const label = gtk_label_new(title);
+  gtk_misc_set_alignment(GTK_MISC(label), 1, 0);
+  gtk_table_attach(GTK_TABLE(users_details_table),
+                   label,
+                   0, 1,                /* left/right_attach */
+                   row, row+1,          /* top/bottom_attach */
+                   GTK_FILL,            /* xoptions */
+                   0,                   /* yoptions */
+                   1, 1);               /* x/ypadding */
+  gtk_table_attach(GTK_TABLE(users_details_table),
+                   selector,
+                   1, 2,                /* left/right_attach */
+                   row, row + 1,        /* top/bottom_attach */
+                   GTK_EXPAND|GTK_FILL, /* xoptions */
+                   GTK_FILL,            /* yoptions */
+                   1, 1);               /* x/ypadding */
+}
+
+/** @brief Add a row to the user details table
+ * @param entryp Where to put GtkEntry
+ * @param title Label for this row
+ * @param value Initial value or NULL
+ * @param flags Flags word
+ */
+static void users_add_detail(GtkWidget **entryp,
+                             const char *title,
+                             const char *value,
+                             unsigned flags) {
+  GtkWidget *entry;
+
+  if(!(entry = *entryp)) {
+    *entryp = entry = gtk_entry_new();
+    users_detail_generic(title, entry);
+  }
+  gtk_entry_set_visibility(GTK_ENTRY(entry),
+                           !!(flags & DETAIL_VISIBLE));
+  gtk_editable_set_editable(GTK_EDITABLE(entry),
+                            !!(flags & DETAIL_EDITABLE));
+  gtk_entry_set_text(GTK_ENTRY(entry), value ? value : "");
+}
+
+/** @brief Add a checkbox for a right
+ * @param title Label for this row
+ * @param value Current value
+ * @param right Right bit
+ */
+static void users_add_right(const char *title,
+                            rights_type value,
+                            rights_type right) {
+  GtkWidget *check;
+  GtkWidget **checkp = &users_details_rights[leftmost_bit(right)];
+
+  if(!(check = *checkp)) {
+    *checkp = check = gtk_check_button_new_with_label("");
+    users_detail_generic(title, check);
+  }
+  gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(check), !!(value & right));
+}
+
+/** @brief Set sensitivity of particular mine/random rights bits */
+static void users_details_sensitize(rights_type r) {
+  const int bit = leftmost_bit(r);
+  const GtkWidget *all = users_details_rights[bit];
+  const int sensitive = (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(all))
+                         && users_mode != MODE_NONE);
+
+  gtk_widget_set_sensitive(users_details_rights[bit + 1], sensitive);
+  gtk_widget_set_sensitive(users_details_rights[bit + 2], sensitive);
+}
+
+/** @brief Set sensitivity of everything in sight */
+static void users_details_sensitize_all(void) {
+  int n;
+
+  for(n = 0; n < 32; ++n)
+    if(users_details_rights[n])
+      gtk_widget_set_sensitive(users_details_rights[n], users_mode != MODE_NONE);
+  gtk_widget_set_sensitive(users_details_name, users_mode != MODE_NONE);
+  gtk_widget_set_sensitive(users_details_email, users_mode != MODE_NONE);
+  gtk_widget_set_sensitive(users_details_password, users_mode != MODE_NONE);
+  gtk_widget_set_sensitive(users_details_password2, users_mode != MODE_NONE);
+  users_details_sensitize(RIGHT_MOVE_ANY);
+  users_details_sensitize(RIGHT_REMOVE_ANY);
+  users_details_sensitize(RIGHT_SCRATCH_ANY);
+  gtk_widget_set_sensitive(users_apply_button, users_mode != MODE_NONE);
+  gtk_widget_set_sensitive(users_delete_button, !!users_selected);
+}
+
+/** @brief Called when an _ALL widget is toggled
+ *
+ * Modifies sensitivity of the corresponding _MINE and _RANDOM widgets.  We
+ * just do the lot rather than trying to figure out which one changed,
+ */
+static void users_any_toggled(GtkToggleButton attribute((unused)) *togglebutton,
+                              gpointer attribute((unused)) user_data) {
+  users_details_sensitize_all();
+}
+
+/** @brief Add a checkbox for a three-right group
+ * @param title Label for this row
+ * @param bits Rights bits (not masked or normalized)
+ * @param mask Mask for this group (must be 7*2^n)
+ */
+static void users_add_right_group(const char *title,
+                                  rights_type bits,
+                                  rights_type mask) {
+  const uint32_t first = mask / 7;
+  const int bit = leftmost_bit(first);
+  GtkWidget **widgets = &users_details_rights[bit], *any, *mine, *rnd;
+
+  if(!*widgets) {
+    GtkWidget *hbox = gtk_hbox_new(FALSE, 2);
+
+    any = widgets[0] = gtk_check_button_new_with_label("Any");
+    mine = widgets[1] = gtk_check_button_new_with_label("Own");
+    rnd = widgets[2] = gtk_check_button_new_with_label("Random");
+    gtk_box_pack_start(GTK_BOX(hbox), any, FALSE, FALSE, 0);
+    gtk_box_pack_start(GTK_BOX(hbox), mine, FALSE, FALSE, 0);
+    gtk_box_pack_start(GTK_BOX(hbox), rnd, FALSE, FALSE, 0);
+    users_detail_generic(title, hbox);
+    g_signal_connect(any, "toggled", G_CALLBACK(users_any_toggled), NULL);
+    users_details_rights[bit] = any;
+    users_details_rights[bit + 1] = mine;
+    users_details_rights[bit + 2] = rnd;
+  } else {
+    any = widgets[0];
+    mine = widgets[1];
+    rnd = widgets[2];
+  }
+  /* Discard irrelevant bits */
+  bits &= mask;
+  /* Shift down to bits 0-2; the mask is always 3 contiguous bits */
+  bits >>= bit;
+  gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(any), !!(bits & 1));
+  gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(mine), !!(bits & 2));
+  gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(rnd), !!(bits & 4));
+}
+
+/** @brief Called when the details table is destroyed */
+static void users_details_destroyed(GtkWidget attribute((unused)) *widget,
+                                    GtkWidget attribute((unused)) **wp) {
+  users_details_table = 0;
+  g_object_unref(users_list);
+  users_list = 0;
+  users_details_name = 0;
+  users_details_email = 0;
+  users_details_password = 0;
+  users_details_password2 = 0;
+  memset(users_details_rights, 0, sizeof users_details_rights);
+  /* also users_selection?  Not AFAICT; _get_selection does upref */
+}
+
+/** @brief Create or modify the user details table
+ * @param name User name (users_edit()) or NULL (users_add())
+ * @param email Email address
+ * @param rights User rights string
+ * @param password Password
+ */
+static void users_makedetails(const char *name,
+                              const char *email,
+                              const char *rights,
+                              const char *password,
+                              unsigned nameflags,
+                              unsigned flags) {
+  rights_type r = 0;
+  
+  /* Create the table if it doesn't already exist */
+  if(!users_details_table) {
+    users_details_table = gtk_table_new(4, 2, FALSE/*!homogeneous*/);
+    g_signal_connect(users_details_table, "destroy",
+                     G_CALLBACK(users_details_destroyed), 0);
+  }
+
+  /* Create or update the widgets */
+  users_add_detail(&users_details_name, "Username", name,
+                   (DETAIL_EDITABLE|DETAIL_VISIBLE) & nameflags);
+
+  users_add_detail(&users_details_email, "Email", email,
+                   (DETAIL_EDITABLE|DETAIL_VISIBLE) & flags);
+
+  users_add_detail(&users_details_password, "Password", password,
+                   DETAIL_EDITABLE & flags);
+  users_add_detail(&users_details_password2, "Password", password,
+                   DETAIL_EDITABLE & flags);
+
+  parse_rights(rights, &r, 0);
+  users_add_right("Read operations", r, RIGHT_READ);
+  users_add_right("Play track", r, RIGHT_PLAY);
+  users_add_right_group("Move", r, RIGHT_MOVE__MASK);
+  users_add_right_group("Remove", r, RIGHT_REMOVE__MASK);
+  users_add_right_group("Scratch", r, RIGHT_SCRATCH__MASK);
+  users_add_right("Set volume", r, RIGHT_VOLUME);
+  users_add_right("Admin operations", r, RIGHT_ADMIN);
+  users_add_right("Rescan", r, RIGHT_RESCAN);
+  users_add_right("Register new users", r, RIGHT_REGISTER);
+  users_add_right("Modify own userinfo", r, RIGHT_USERINFO);
+  users_add_right("Modify track preferences", r, RIGHT_PREFS);
+  users_add_right("Modify global preferences", r, RIGHT_GLOBAL_PREFS);
+  users_add_right("Pause/resume tracks", r, RIGHT_PAUSE);
+  users_details_sensitize_all();
+}
+
+/** @brief Called when the 'add' button is pressed */
+static void users_add(GtkButton attribute((unused)) *button,
+                     gpointer attribute((unused)) userdata) {
+  /* Unselect whatever is selected */
+  gtk_tree_selection_unselect_all(users_selection);
+  /* Reset the form */
+  /* TODO it would be better to use the server default_rights if there's no
+   * client setting. */
+  users_makedetails("",
+                    "",
+                    config->default_rights,
+                    "",
+                    DETAIL_EDITABLE|DETAIL_VISIBLE,
+                    DETAIL_EDITABLE|DETAIL_VISIBLE);
+  /* Remember we're adding a user */
+  mode(ADD);
+}
+
+static rights_type users_get_rights(void) {
+  rights_type r = 0;
+  int n;
+
+  /* Extract the rights value */
+  for(n = 0; n < 32; ++n) {
+    if(users_details_rights[n])
+      if(gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(users_details_rights[n])))
+         r |= 1 << n;
+  }
+  /* Throw out redundant bits */
+  if(r & RIGHT_REMOVE_ANY)
+    r &= ~(rights_type)(RIGHT_REMOVE_MINE|RIGHT_REMOVE_RANDOM);
+  if(r & RIGHT_MOVE_ANY)
+    r &= ~(rights_type)(RIGHT_MOVE_MINE|RIGHT_MOVE_RANDOM);
+  if(r & RIGHT_SCRATCH_ANY)
+    r &= ~(rights_type)(RIGHT_SCRATCH_MINE|RIGHT_SCRATCH_RANDOM);
+  return r;
+}
+
+/** @brief Called when various things fail */
+static void users_op_failed(struct callbackdata attribute((unused)) *cbd,
+                            int attribute((unused)) code,
+                            const char *msg) {
+  popup_submsg(users_window, GTK_MESSAGE_ERROR, msg);
+}
+
+/** @brief Called when a new user has been created */
+static void users_adduser_completed(void *v) {
+  struct callbackdata *cbd = v;
+
+  /* Now the user is created we can go ahead and set the email address */
+  if(*cbd->u.edituser.email) {
+    struct callbackdata *ncbd = xmalloc(sizeof *cbd);
+    ncbd->onerror = users_op_failed;
+    disorder_eclient_edituser(client, NULL, cbd->u.edituser.user,
+                              "email", cbd->u.edituser.email, ncbd);
+  }
+  /* Refresh the list of users */
+  disorder_eclient_users(client, users_got_list, 0);
+  /* We'll select the newly created user */
+  users_deferred_select = cbd->u.edituser.user;
+}
+
+/** @brief Called if creating a new user fails */
+static void users_adduser_failed(struct callbackdata attribute((unused)) *cbd,
+                                 int attribute((unused)) code,
+                                 const char *msg) {
+  popup_submsg(users_window, GTK_MESSAGE_ERROR, msg);
+  mode(ADD);                            /* Let the user try again */
+}
+
+/** @brief Called when the 'Apply' button is pressed */
+static void users_apply(GtkButton attribute((unused)) *button,
+                        gpointer attribute((unused)) userdata) {
+  struct callbackdata *cbd;
+  const char *password;
+  const char *password2;
+  const char *name;
+  const char *email;
+
+  switch(users_mode) {
+  case MODE_NONE:
+    return;
+  case MODE_ADD:
+    name = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_name)));
+    email = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_email)));
+    password = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_password)));
+    password2 = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_password2)));
+    if(!*name) {
+      /* No username.  Really we wanted to desensitize the Apply button when
+       * there's no userame but there doesn't seem to be a signal to detect
+       * changes to the entry text.  Consequently we have error messages
+       * instead.  */
+      popup_submsg(users_window, GTK_MESSAGE_ERROR, "Must enter a username");
+      return;
+    }
+    if(strcmp(password, password2)) {
+      popup_submsg(users_window, GTK_MESSAGE_ERROR, "Passwords do not match");
+      return;
+    }
+    if(*email && !strchr(email, '@')) {
+      /* The server will complain about this but we can give a better error
+       * message this way */
+      popup_submsg(users_window, GTK_MESSAGE_ERROR, "Invalid email address");
+      return;
+    }
+    cbd = xmalloc(sizeof *cbd);
+    cbd->onerror = users_adduser_failed;
+    cbd->u.edituser.user = name;
+    cbd->u.edituser.email = email;
+    disorder_eclient_adduser(client, users_adduser_completed,
+                             name,
+                             password,
+                             rights_string(users_get_rights()),
+                             cbd);
+    /* We switch to no-op mode while creating the user */
+    mode(NONE);
+    break;
+  case MODE_EDIT:
+    /* Ugh, can we de-dupe with above? */
+    email = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_email)));
+    password = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_password)));
+    password2 = xstrdup(gtk_entry_get_text(GTK_ENTRY(users_details_password2)));
+    if(strcmp(password, password2)) {
+      popup_submsg(users_window, GTK_MESSAGE_ERROR, "Passwords do not match");
+      return;
+    }
+    if(*email && !strchr(email, '@')) {
+      popup_submsg(users_window, GTK_MESSAGE_ERROR, "Invalid email address");
+      return;
+    }
+    cbd = xmalloc(sizeof *cbd);
+    cbd->onerror = users_op_failed;
+    disorder_eclient_edituser(client, NULL, users_selected,
+                              "email", email, cbd);
+    disorder_eclient_edituser(client, NULL, users_selected,
+                              "password", password, cbd);
+    disorder_eclient_edituser(client, NULL, users_selected,
+                              "rights", rights_string(users_get_rights()), cbd);
+    /* We remain in edit mode */
+    break;
+  }
+}
+
+/** @brief Called when a user has been deleted */
+static void users_deleted(void *v) {
+  const struct callbackdata *const cbd = v;
+  GtkTreeIter iter[1];
+
+  if(!users_find_user(cbd->u.user, iter))    /* Find the user... */
+    gtk_list_store_remove(users_list, iter); /* ...and remove them */
+}
+
+/** @brief Called when the 'Delete' button is pressed */
+static void users_delete(GtkButton attribute((unused)) *button,
+                        gpointer attribute((unused)) userdata) {
+  GtkWidget *yesno;
+  int res;
+  struct callbackdata *cbd;
+
+  if(!users_selected)
+    return;
+  yesno = gtk_message_dialog_new(GTK_WINDOW(users_window),
+                                 GTK_DIALOG_MODAL,
+                                 GTK_MESSAGE_QUESTION,
+                                 GTK_BUTTONS_YES_NO,
+                                 "Do you really want to delete user %s?"
+                                 " This action cannot be undone.",
+                                 users_selected);
+  res = gtk_dialog_run(GTK_DIALOG(yesno));
+  gtk_widget_destroy(yesno);
+  if(res == GTK_RESPONSE_YES) {
+    cbd = xmalloc(sizeof *cbd);
+    cbd->onerror = users_op_failed;
+    cbd->u.user = users_selected;
+    disorder_eclient_deluser(client, users_deleted, cbd->u.user, cbd);
+  }
+}
+
+static void users_got_email(void attribute((unused)) *v, const char *value) {
+  users_email = value;
+}
+
+static void users_got_rights(void attribute((unused)) *v, const char *value) {
+  users_rights = value;
+}
+
+static void users_got_password(void attribute((unused)) *v, const char *value) {
+  users_password = value;
+  users_makedetails(users_selected,
+                    users_email,
+                    users_rights,
+                    users_password,
+                    DETAIL_VISIBLE,
+                    DETAIL_EDITABLE|DETAIL_VISIBLE);
+  mode(EDIT);
+}
+
+/** @brief Called when the selection MIGHT have changed */
+static void users_selection_changed(GtkTreeSelection attribute((unused)) *treeselection,
+                                    gpointer attribute((unused)) user_data) {
+  GtkTreeIter iter;
+  char *gselected, *selected;
+  
+  /* Identify the current selection */
+  if(gtk_tree_selection_get_selected(users_selection, 0, &iter)) {
+    gtk_tree_model_get(GTK_TREE_MODEL(users_list), &iter,
+                       0, &gselected, -1);
+    selected = xstrdup(gselected);
+    g_free(gselected);
+  } else
+    selected = 0;
+  /* Eliminate no-change cases */
+  if(!selected && !users_selected)
+    return;
+  if(selected && users_selected && !strcmp(selected, users_selected))
+    return;
+  /* There's been a change; junk the old data and fetch new data in
+   * background. */
+  users_selected = selected;
+  users_makedetails("", "", "", "",
+                    DETAIL_VISIBLE,
+                    DETAIL_VISIBLE);
+  if(users_selected) {
+    disorder_eclient_userinfo(client, users_got_email, users_selected,
+                              "email", 0);
+    disorder_eclient_userinfo(client, users_got_rights, users_selected,
+                              "rights", 0);
+    disorder_eclient_userinfo(client, users_got_password, users_selected,
+                              "password", 0);
+  }
+  mode(NONE);                           /* not editing *yet* */
+}
+
+/** @brief Table of buttons below the user list */
+static struct button users_buttons[] = {
+  {
+    GTK_STOCK_ADD,
+    users_add,
+    "Create a new user",
+    0
+  },
+  {
+    GTK_STOCK_REMOVE,
+    users_delete,
+    "Delete a user",
+    0
+  },
+};
+#define NUSERS_BUTTONS (sizeof users_buttons / sizeof *users_buttons)
+
+/** @brief Pop up the user management window */
+void manage_users(void) {
+  GtkWidget *tree, *buttons, *hbox, *hbox2, *vbox, *vbox2;
+  GtkCellRenderer *cr;
+  GtkTreeViewColumn *col;
+  
+  /* If the window already exists just raise it */
+  if(users_window) {
+    gtk_window_present(GTK_WINDOW(users_window));
+    return;
+  }
+  /* Create the window */
+  users_window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
+  gtk_widget_set_style(users_window, tool_style);
+  g_signal_connect(users_window, "destroy",
+                  G_CALLBACK(gtk_widget_destroyed), &users_window);
+  gtk_window_set_title(GTK_WINDOW(users_window), "User Management");
+  /* default size is too small */
+  gtk_window_set_default_size(GTK_WINDOW(users_window), 240, 240);
+
+  /* Create the list of users and populate it asynchronously */
+  users_list = gtk_list_store_new(1, G_TYPE_STRING);
+  disorder_eclient_users(client, users_got_list, 0);
+  /* Create the view */
+  tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(users_list));
+  /* ...and the renderers for it */
+  cr = gtk_cell_renderer_text_new();
+  col = gtk_tree_view_column_new_with_attributes("Username",
+                                                cr,
+                                                "text", 0,
+                                                NULL);
+  gtk_tree_view_append_column(GTK_TREE_VIEW(tree), col);
+  /* Get the selection for the view; set its mode; arrange for a callback when
+   * it changes */
+  users_selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree));
+  gtk_tree_selection_set_mode(users_selection, GTK_SELECTION_BROWSE);
+  g_signal_connect(users_selection, "changed",
+                   G_CALLBACK(users_selection_changed), NULL);
+
+  /* Create the control buttons */
+  buttons = create_buttons_box(users_buttons,
+                              NUSERS_BUTTONS,
+                              gtk_hbox_new(FALSE, 1));
+  users_delete_button = users_buttons[1].widget;
+
+  /* Buttons live below the list */
+  vbox = gtk_vbox_new(FALSE, 0);
+  gtk_box_pack_start(GTK_BOX(vbox), scroll_widget(tree), TRUE/*expand*/, TRUE/*fill*/, 0);
+  gtk_box_pack_start(GTK_BOX(vbox), buttons, FALSE/*expand*/, FALSE, 0);
+
+  /* Create an empty user details table, and put an apply button below it */
+  users_apply_button = gtk_button_new_from_stock(GTK_STOCK_APPLY);
+  users_makedetails("", "", "", "",
+                    DETAIL_VISIBLE,
+                    DETAIL_VISIBLE);
+  g_signal_connect(users_apply_button, "clicked",
+                   G_CALLBACK(users_apply), NULL);
+  hbox2 = gtk_hbox_new(FALSE, 0);
+  gtk_box_pack_end(GTK_BOX(hbox2), users_apply_button,
+                   FALSE/*expand*/, FALSE, 0);
+  
+  vbox2 = gtk_vbox_new(FALSE, 0);
+  gtk_box_pack_start(GTK_BOX(vbox2), users_details_table,
+                     TRUE/*expand*/, TRUE/*fill*/, 0);
+  gtk_box_pack_start(GTK_BOX(vbox2), hbox2,
+                     FALSE/*expand*/, FALSE, 0);
+  
+  /* User details are to the right of the list.  We put in a pointless event
+   * box as as spacer, so that the longest label in the user details isn't
+   * cuddled up to the user list. */
+  hbox = gtk_hbox_new(FALSE, 0);
+  gtk_box_pack_start(GTK_BOX(hbox), vbox, FALSE/*expand*/, FALSE, 0);
+  gtk_box_pack_start(GTK_BOX(hbox), gtk_event_box_new(), FALSE/*expand*/, FALSE, 2);
+  gtk_box_pack_start(GTK_BOX(hbox), vbox2, TRUE/*expand*/, TRUE/*fill*/, 0);
+  gtk_container_add(GTK_CONTAINER(users_window), frame_widget(hbox, NULL));
+}
+
+/*
+Local Variables:
+c-basic-offset:2
+comment-column:40
+fill-column:79
+indent-tabs-mode:nil
+End:
+*/
index 2c7ee789c1e04157be99f1b259dc6b8e76776be2..57e3addc1263679f42416bfb38cf2e9ffe2d9fda 100644 (file)
@@ -26,12 +26,15 @@ disobedience \- GUI client for DisOrder jukebox
 .B disobedience
 is a graphical client for DisOrder.
 .SH "WINDOWS AND ICONS"
-.SS "File Menu"
+.SS "Server Menu"
 This has the following options:
 .TP
 .B Login
 Brings up the \fBLogin Details Window\fR; see below.
 .TP
+.B "Manage Users"
+Brings up the \fBUser Management Window\fR; see below.
+.TP
 .B Quit
 Terminates the program.
 .SS "Edit Menu"
@@ -258,6 +261,10 @@ The Tags field determine which tags apply to the track.
 Tags are separated by commas and can contain any printing characters except
 comma.
 .PP
+The Weight field determines the track weight.  Tracks with higher weights are
+proportionately more likely to be picked at random.  The default weight is
+90000, and the maximum weight is 2147483647.
+.PP
 The Random checkbox determines whether the track will be picked at random.
 Random play is enabled for every track by default, but it can be turned off
 here.
@@ -270,6 +277,23 @@ fields, or bulk-disable random play for many tracks.
 Press "OK" to confirm all changes and close the window, "Apply" to confirm
 changes but keep the window open and "Cancel" to close the window and discard
 all changes.
+.SS "User Management Window"
+This window is primarily of interest to adminstrators, and will not be
+available to users without admin rights.  The left hand side is a list of all
+users; the right hand side contains the editable details of the currently
+selected user.
+.PP
+When you select any user you can edit their email address or change their
+password.  It is also possible to edit the individual user rights.  Click on
+the "Apply" button to commit any changes you make.
+.PP
+The "Add" button creates a new user.  You must enter at least a username.
+Default rights are setting according to local configuration, \fInot\fR server
+configuration (but this may be changed in the future).  As above, click on
+"Apply" to actually create the new user.
+.PP
+The "Delete" button deletes the selected user.  This operation cannot be
+undone.
 .SH "KEYBOARD SHORTCUTS"
 .TP
 .B CTRL+A
index a056d63c00262221d386231066cb379e26478527..e1981f62df5b218181f1791dd1e8005fe3aaac36 100644 (file)
@@ -55,25 +55,25 @@ Display version number.
 List all known commands.
 .SH COMMANDS
 .TP
-.B adduser \fIUSER PASSWORD\fR [\fIRIGHTS\fR]
+.B adduser \fIUSERNAME PASSWORD\fR [\fIRIGHTS\fR]
 Create a new user.
 If \fIRIGHTS\fR is not specified then the \fBdefault_rights\fR
 setting from the server's configuration file applies.
 .TP
-.B authorize \fIUSER\fR [\fIRIGHTS\fR]
-Create \fIUSER\fR with a random password.
-\fIUSER\fR must be a UNIX login user (not just any old string).
+.B authorize \fIUSERNAME\fR [\fIRIGHTS\fR]
+Create user \fIUSERNAME\fR with a random password.
+User \fIUSERNAME\fR must be a UNIX login user (not just any old string).
 If \fIRIGHTS\fR is not specified then the \fBdefault_rights\fR
 setting from the server's configuration file applies.
 .IP
-\fI~USER/.disorder/passwd\fR is created with the password in it, so the new
+\fI~USERNAME/.disorder/passwd\fR is created with the password in it, so the new
 user should be able to log in immediately.
 .IP
 If writing the \fIpasswd\fR file fails then the user will already have been
 created in DisOrder's user database.
 Use \fBdisorder deluser\fR to remove them before trying again.
 .TP
-.B deluser \fIUSER\fR
+.B deluser \fIUSERNAME\fR
 Delete a user.
 .TP
 .B dirs \fIDIRECTORY\fR [\fB~\fIREGEXP\fR]
@@ -85,7 +85,7 @@ Only directories with a basename matching the regexp will be returned.
 .B disable
 Disable playing after the current track finishes.
 .TP
-.B edituser \fIUSER PROPERTY VALUE
+.B edituser \fIUSERNAME PROPERTY VALUE
 Set some property of a user.
 .TP
 .B enable
@@ -238,7 +238,7 @@ Unset the preference \fIKEY\fR for \fITRACK\fR.
 .B unset\-global \fIKEY\fR
 Unset the global preference \fIKEY\fR.
 .TP
-.B userinfo \fIUSER PROPERTY
+.B userinfo \fIUSERNAME PROPERTY
 Get some property of a user.
 .TP
 .B users
@@ -299,6 +299,22 @@ if the full version is not present.
 .B unscratched
 The number of times the track has been played to completion without
 being scratched.
+.TP
+.B weight
+The weight for this track.  Weights are non-negative integers which determine
+the relative likelihood of a track being picked at random (i.e. if track A has
+twice the weight of track B then it is twice as likely to be picked at random).
+A track with weight 0 will not be picked at random, though \fBpick_at_random\fR
+is a more sensible way to configure this.
+.IP
+The default weight, used if no weight is set or the weight value is invalid, is
+90000.  Note that many other factors than track weight affect whether a track
+will be played - tracks already in the queue will not be picked at random for
+instance.
+.IP
+The maximum allowed weight is 2147483647.  If you set a larger value it will be
+clamped to this value.  Negative weights will be completely ignored and the
+default value used instead.
 .SH NOTES
 .B disorder
 is locale-aware.
index f3bbe9175d35767fa42c2317450dcdb928df33b3..ae54acd82049d5434f79dfde31bbd99f73240731 100644 (file)
@@ -157,15 +157,6 @@ and are lost if the track is deleted; they should only ever have
 values that can be regenerated on demand.
 Other values are stored in the prefs database and never get
 automatically deleted.
-.PP
-.nf
-\fBconst char *disorder_track_random(void)
-.fi
-.IP
-Returns a pointer to a copy of the name of a randomly chosen track.
-Each non-alias track has an equal probability of being chosen.
-Aliases are never returned.
-Only available in server plugins.
 .SH "PLUGIN FUNCTIONS"
 This section describes the functions that you must implement to write various
 plugins.
index 3641adf1dc2a5a241b06f845424681bb34cdeef5..a9549ed61f2b5a2548f42483b9e3490590279114 100644 (file)
@@ -214,12 +214,12 @@ Configuration files are read in the following order:
 Should be readable only by the jukebox group.
 Not really useful any more and will be abolished in future.
 .TP
-.I ~\fRUSER\fI/.disorder/passwd
+.I ~\fRUSERNAME\fI/.disorder/passwd
 Per-user client configuration.
 Optional but if it exists must be readable only by the relevant user.
 Would normally contain a \fBpassword\fR directive.
 .TP
-.I pkgconfdir/config.\fRUSER
+.I pkgconfdir/config.\fRUSERNAME
 Per-user system-controlled client configuration.
 Optional but if it exists must be readable only by the relevant user.
 Would normally contain a \fBpassword\fR directive.
@@ -368,6 +368,15 @@ It's best to explicitly specify it to be certain.
 passed to the plugin module.
 It must be an absolute path and should not end with a "/".
 .TP
+.B cookie_key_lifetime \fISECONDS\fR
+Lifetime of the signing key used in constructing cookies.  The default is one
+week.
+.TP
+.B cookie_login_lifetime \fISECONDS\fR
+Lifetime of a cookie enforced by the server.  When the cookie expires the user
+will have to log in again even if their browser has remembered the cookie that
+long.  The default is one day.
+.TP
 .B default_rights \fIRIGHTS\fR
 Defines the set of rights given to new users.
 The argument is a comma-separated list of rights.
@@ -395,6 +404,9 @@ default is.
 .B gap \fISECONDS\fR
 Specifies the number of seconds to leave between tracks.
 The default is 0.
+.IP
+NB this option currently DOES NOT WORK.  If there is genuine demand it might be
+reinstated.
 .TP
 .B history \fIINTEGER\fR
 Specifies the number of recently played tracks to remember (including
@@ -581,6 +593,12 @@ The default is 10.
 The minimum number of seconds that must elapse between password reminders.
 The default is 600, i.e. 10 minutes.
 .TP
+.B replay_min \fISECONDS\fR
+The minimum number of seconds that must elapse after a track has been played
+before it can be picked at random.  The default is 8 hours.  If this is set to
+0 then there is no limit, though current \fBdisorder-choose\fR will not pick
+anything currently listed in the recently-played list.
+.TP
 .B sample_format \fIBITS\fB/\fIRATE\fB/\fICHANNELS
 Describes the sample format expected by the \fBspeaker_command\fR (below).
 The components of the format specification are as follows:
@@ -673,7 +691,7 @@ Specifies the module used to calculate the length of files matching
 If \fBtracklength\fR is used without arguments then the list of modules is
 cleared.
 .TP
-.B user \fIUSER\fR
+.B user \fIUSERNAME\fR
 Specifies the user to run as.
 Only makes sense if invoked as root (or the target user).
 .SS "Client Configuration"
@@ -750,8 +768,9 @@ longer needs to be specified.
 This must be the full URL, e.g. \fBhttp://myhost/cgi-bin/jukebox\fR and not
 \fB/cgi-bin/jukebox\fR.
 .SS "Authentication Configuration"
-These options would normally be used in \fI~\fRUSER\fI/.disorder/passwd\fR or
-\fIpkgconfdir/config.\fRUSER.
+These options would normally be used in \fI~\fRUSERNAME\fI/.disorder/passwd\fR
+or
+\fIpkgconfdir/config.\fRUSERNAME.
 .TP
 .B password \fIPASSWORD\fR
 Specify password.
index cc88541a44878864ea490e523a719ff52d5d2796..5a2bf839a25e5601ad66b4e84824b48979ee3f4b 100644 (file)
@@ -91,6 +91,10 @@ Set a user property.
 With the \fBadmin\fR right any username and property may be specified.
 Otherwise the \fBuserinfo\fR right is required and only the
 \fBemail\fR and \fBpassword\fR properties may be set.
+.IP
+User properties are syntax-checked before setting.  For instance \fBemail\fR
+must contain an "@" sign or you will get an error.  (Setting an empty value for
+\fBemail\fR is allowed and removes the property.)
 .TP
 .B enable
 Re-enable further playing, and is the opposite of \fBdisable\fR.
@@ -233,15 +237,15 @@ See below for the track information syntax.
 Request that DisOrder reconfigure itself.
 Requires the \fBadmin\fR right.
 .TP
-.B register \fIUSER PASSWORD EMAIL
+.B register \fIUSERNAME PASSWORD EMAIL
 Register a new user.
 Requires the \fBregister\fR right.
 The result contains a confirmation string; the user will be be able
 to log in until this has been presented back to the server via the
 \fBconfirm\fR command.
 .TP
-.B reminder \fIUSER\fR
-Send a password reminder to \fIUSER\fR.
+.B reminder \fIUSERNAME\fR
+Send a password reminder to user \fIUSERNAME\fR.
 If the user has no valid email address, or no password, or a
 reminder has been sent too recently, then no reminder will be sent.
 .TP
@@ -317,12 +321,15 @@ Requires the \fBprefs\fR right.
 Unset a global preference.
 Requires the \fBglobal prefs\fR right.
 .TP
-.B user \fIUSER\fR \fIRESPONSE\fR
-Authenticate as \fIUSER\fR.
+.B user \fIUSERNAME\fR \fIRESPONSE\fR
+Authenticate as user \fIUSERNAME\fR.
 See
 .B AUTHENTICATION
 below.
 .TP
+.B userinfo \fIUSERNAME PROPERTY
+Get a user property.
+.TP
 .B users
 Send the list of currently known users in a response body.
 .TP
@@ -487,11 +494,11 @@ Completed playing \fITRACK\fR
 .B failed \fITRACK\fR \fIERROR\fR
 Completed playing \fITRACK\fR with an error status
 .TP
-.B moved \fIUSER\fR
-User \fIUSER\fR moved some track(s).
+.B moved \fIUSERNAME\fR
+User \fIUSERNAME\fR moved some track(s).
 Further details aren't included any more.
 .TP
-.B playing \fITRACK\fR [\fIUSER\fR]
+.B playing \fITRACK\fR [\fIUSERNAME\fR]
 Started playing \fITRACK\fR.
 .TP
 .B queue \fIQUEUE-ENTRY\fR...
@@ -503,16 +510,16 @@ Added \fIID\fR to the recently played list.
 .B recent_removed \fIID\fR
 Removed \fIID\fR from the recently played list.
 .TP
-.B removed \fIID\fR [\fIUSER\fR]
+.B removed \fIID\fR [\fIUSERNAME\fR]
 Queue entry \fIID\fR was removed.
-This is used both for explicit removal (when \fIUSER\fR is present)
+This is used both for explicit removal (when \fIUSERNAME\fR is present)
 and when playing a track (when it is absent).
 .TP
 .B rescanned
 A rescan completed.
 .TP
-.B scratched \fITRACK\fR \fIUSER\fR
-\fITRACK\fR was scratched by \fIUSER\fR.
+.B scratched \fITRACK\fR \fIUSERNAME\fR
+\fITRACK\fR was scratched by \fIUSERNAME\fR.
 .TP
 .B state \fIKEYWORD\fR
 Some state change occurred.
index 5402be3954d4bcd0d0c9deefce4272e833d1f462..ecd106f43272299e1f12ae03b80626eaad69220f 100644 (file)
@@ -34,6 +34,7 @@ libdisorder_a_SOURCES=charset.c charset.h             \
        authhash.c authhash.h                           \
        basen.c basen.h                                 \
        base64.c base64.h                               \
+       bits.c bits.h                                   \
        cache.c cache.h                                 \
        client.c client.h                               \
        client-common.c client-common.h                 \
@@ -118,7 +119,7 @@ test_SOURCES=test.c memgc.c test.h t-addr.c t-basen.c t-cache.c             \
        t-casefold.c t-cookies.c t-filepart.c t-hash.c t-heap.c         \
        t-hex.c t-kvp.c t-mime.c t-printf.c t-regsub.c t-selection.c    \
        t-signame.c t-sink.c t-split.c t-unicode.c t-url.c t-utf8.c     \
-       t-words.c t-wstat.c
+       t-words.c t-wstat.c t-bits.c
 test_LDADD=libdisorder.a $(LIBPCRE) $(LIBICONV) $(LIBGC)
 test_DEPENDENCIES=libdisorder.a
 
diff --git a/lib/bits.c b/lib/bits.c
new file mode 100644 (file)
index 0000000..6b5273b
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * This file is part of DisOrder
+ * Copyright (C) 2008 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
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+ * USA
+ */
+
+/** @file lib/bits.c
+ * @brief Bit operations
+ */
+
+#include <config.h>
+#include "types.h"
+
+#include <math.h>
+
+#include "bits.h"
+
+#if !HAVE_FLS
+/** @brief Compute index of leftmost 1 bit
+ * @param n Integer
+ * @return Index of leftmost 1 bit or -1
+ *
+ * For positive @p n we return the index of the leftmost bit of @p n.  For
+ * instance @c leftmost_bit(1) returns 0, @c leftmost_bit(15) returns 3, etc.
+ *
+ * If @p n is zero then -1 is returned.
+ */
+int leftmost_bit(uint32_t n) {
+  /* See e.g. Hacker's Delight s5-3 (p81) for where the idea comes from.
+   * Warren is computing the number of leading zeroes, but that's not quite
+   * what I wanted.  Also this version should be more portable than his, which
+   * inspects the bytes of the floating point number directly.
+   */
+  int x;
+  frexp((double)n, &x);
+  /* This gives: n = m * 2^x, where 0.5 <= m < 1 and x is an integer.
+   *
+   * If we take log2 of either side then we have:
+   *    log2(n) = x + log2 m
+   *
+   * We know that 0.5 <= m < 1 => -1 <= log2 m < 0.  So we floor either side:
+   *
+   *    floor(log2(n)) = x - 1
+   *
+   * What is floor(log2(n))?  Well, consider that:
+   *
+   *    2^k <= z < 2^(k+1)  =>  floor(log2(z)) = k.
+   *
+   * But 2^k <= z < 2^(k+1) is the same as saying that the leftmost bit of z is
+   * bit k.
+   *
+   *
+   * Warren adds 0.5 first, to deal with the case when n=0.  However frexp()
+   * guarantees to return x=0 when n=0, so we get the right answer without that
+   * step.
+   */
+  return x - 1;
+}
+#endif
+
+/*
+Local Variables:
+c-basic-offset:2
+comment-column:40
+fill-column:79
+indent-tabs-mode:nil
+End:
+*/
diff --git a/lib/bits.h b/lib/bits.h
new file mode 100644 (file)
index 0000000..0965cb6
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * This file is part of DisOrder
+ * Copyright (C) 2008 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
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+ * USA
+ */
+
+/** @file lib/bits.h
+ * @brief Bit operations
+ */
+
+#ifndef BITS_H
+#define BITS_H
+
+#include <string.h>                     /* for fls() */
+
+#if HAVE_FLS
+static inline int leftmost_bit(uint32_t n) {
+  return fls(n) - 1;
+}
+#else
+int leftmost_bit(uint32_t n);
+#endif
+
+#endif /* BITS_H */
+
+
+/*
+Local Variables:
+c-basic-offset:2
+comment-column:40
+fill-column:79
+indent-tabs-mode:nil
+End:
+*/
index 869f8957c79f49ff731c303f4dc866ee81385bc2..b93e1e9ea16217b06ff9b21cb70c5c1a4486a3f1 100644 (file)
@@ -957,6 +957,7 @@ static const struct conf conf[] = {
   { C(plugins),          &type_string_accum,     validate_isdir },
   { C(prefsync),         &type_integer,          validate_positive },
   { C(queue_pad),        &type_integer,          validate_positive },
+  { C(replay_min),       &type_integer,          validate_non_negative },
   { C(refresh),          &type_integer,          validate_positive },
   { C(reminder_interval), &type_integer,         validate_positive },
   { C2(restrict, restrictions),         &type_restrict,         validate_any },
@@ -1178,6 +1179,7 @@ static struct config *config_default(void) {
   c->sample_format.channels = 2;
   c->sample_format.endian = ENDIAN_NATIVE;
   c->queue_pad = 10;
+  c->replay_min = 8 * 3600;
   c->api = -1;
   c->multicast_ttl = 1;
   c->multicast_loop = 1;
index 93885689f8cfee7270f4a574fde985634edb5e65..de251970855cc103316b2f503bf192e0dc6528ff 100644 (file)
@@ -226,6 +226,9 @@ struct config {
   /** @brief Target queue length */
   long queue_pad;
 
+  /** @brief Minimum time between a track being played again */
+  long replay_min;
+  
   struct namepartlist namepart;                /* transformations */
 
   /** @brief Termination signal for subprocesses */
index 98f60a899d917fd5d1ed4c843b071285f54754f5..a8ca322e071bed886259f8d26d74e5f663713516 100644 (file)
@@ -841,9 +841,11 @@ static void stash_command(disorder_eclient *c,
 static void string_response_opcallback(disorder_eclient *c,
                                        struct operation *op) {
   D(("string_response_callback"));
-  if(c->rc / 100 == 2) {
+  if(c->rc / 100 == 2 || c->rc == 555) {
     if(op->completed) {
-      if(c->protocol >= 2) {
+      if(c->rc == 555)
+        ((disorder_eclient_string_response *)op->completed)(op->v, NULL);
+      else if(c->protocol >= 2) {
         char **rr = split(c->line + 4, 0, SPLIT_QUOTES, 0, 0);
         
         if(rr && *rr)
@@ -1263,6 +1265,86 @@ int disorder_eclient_rtp_address(disorder_eclient *c,
                 "rtp-address", (char *)0);
 }
 
+/** @brief Get the list of users
+ * @param c Client
+ * @param completed Called with list of users
+ * @param v Passed to @p completed
+ *
+ * The user list is not sorted in any particular order.
+ */
+int disorder_eclient_users(disorder_eclient *c,
+                           disorder_eclient_list_response *completed,
+                           void *v) {
+  return simple(c, list_response_opcallback, (void (*)())completed, v,
+                "users", (char *)0);
+}
+
+/** @brief Delete a user
+ * @param c Client
+ * @param completed Called on completion
+ * @param user User to delete
+ * @param v Passed to @p completed
+ */
+int disorder_eclient_deluser(disorder_eclient *c,
+                             disorder_eclient_no_response *completed,
+                             const char *user,
+                             void *v) {
+  return simple(c, no_response_opcallback, (void (*)())completed, v, 
+                "deluser", user, (char *)0);
+}
+
+/** @brief Get a user property
+ * @param c Client
+ * @param completed Called on completion
+ * @param user User to look up
+ * @param property Property to look up
+ * @param v Passed to @p completed
+ */
+int disorder_eclient_userinfo(disorder_eclient *c,
+                              disorder_eclient_string_response *completed,
+                              const char *user,
+                              const char *property,
+                              void *v) {
+  return simple(c, string_response_opcallback,  (void (*)())completed, v, 
+                "userinfo", user, property, (char *)0);
+}
+
+/** @brief Modify a user property
+ * @param c Client
+ * @param completed Called on completion
+ * @param user User to modify
+ * @param property Property to modify
+ * @param value New property value
+ * @param v Passed to @p completed
+ */
+int disorder_eclient_edituser(disorder_eclient *c,
+                              disorder_eclient_no_response *completed,
+                              const char *user,
+                              const char *property,
+                              const char *value,
+                              void *v) {
+  return simple(c, no_response_opcallback, (void (*)())completed, v, 
+                "edituser", user, property, value, (char *)0);
+}
+
+/** @brief Create a new user
+ * @param c Client
+ * @param completed Called on completion
+ * @param user User to create
+ * @param password Initial password
+ * @param rights Initial rights or NULL
+ * @param v Passed to @p completed
+ */
+int disorder_eclient_adduser(disorder_eclient *c,
+                             disorder_eclient_no_response *completed,
+                             const char *user,
+                             const char *password,
+                             const char *rights,
+                             void *v) {
+  return simple(c, no_response_opcallback, (void (*)())completed, v, 
+                "adduser", user, password, rights, (char *)0);
+}
+
 /* Log clients ***************************************************************/
 
 /** @brief Monitor the server log
index 37ec9cbd9edfd80ade9df2b8ffd5f635c3b2b429..157ad59301095a19556e0a2a9121de6e1c5cbf94 100644 (file)
@@ -141,8 +141,14 @@ struct sink;
 typedef void disorder_eclient_no_response(void *v);
 /* completion callback with no data */
 
+/** @brief String result completion callback
+ * @param v User data
+ * @param value or NULL
+ *
+ * @p value can be NULL for disorder_eclient_get(),
+ * disorder_eclient_get_global() and disorder_eclient_userinfo().
+ */
 typedef void disorder_eclient_string_response(void *v, const char *value);
-/* completion callback with a string result */
 
 typedef void disorder_eclient_integer_response(void *v, long value);
 /* completion callback with a integer result */
@@ -326,6 +332,31 @@ int disorder_eclient_rtp_address(disorder_eclient *c,
                                  disorder_eclient_list_response *completed,
                                  void *v);
 
+int disorder_eclient_users(disorder_eclient *c,
+                           disorder_eclient_list_response *completed,
+                           void *v);
+int disorder_eclient_deluser(disorder_eclient *c,
+                             disorder_eclient_no_response *completed,
+                             const char *user,
+                             void *v);
+int disorder_eclient_userinfo(disorder_eclient *c,
+                              disorder_eclient_string_response *completed,
+                              const char *user,
+                              const char *property,
+                              void *v);
+int disorder_eclient_edituser(disorder_eclient *c,
+                              disorder_eclient_no_response *completed,
+                              const char *user,
+                              const char *property,
+                              const char *value,
+                              void *v);
+int disorder_eclient_adduser(disorder_eclient *c,
+                             disorder_eclient_no_response *completed,
+                             const char *user,
+                             const char *password,
+                             const char *rights,
+                             void *v);
+
 #endif
 
 /*
index db8b9404407be8263a6a39288439be8d1c6e9633..0e975962a13f7f8c151de5000e5ff03dd973bdf8 100644 (file)
@@ -1234,7 +1234,7 @@ int ev_writer_flush(ev_writer *w) {
 
 /* buffered reader ************************************************************/
 
-/** @brief Shut down a reader*
+/** @brief Shut down a reader
  *
  * This is the only path through which we cancel and close the file descriptor.
  * As with the writer case it is given timeout signature to allow it be
diff --git a/lib/t-bits.c b/lib/t-bits.c
new file mode 100644 (file)
index 0000000..846abb8
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * This file is part of DisOrder.
+ * Copyright (C) 2008 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
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+ * USA
+ */
+#include "test.h"
+#include "bits.h"
+
+void test_bits(void) {
+  int n;
+  
+  printf("test_bits\n");
+  check_integer(leftmost_bit(0), -1);
+  check_integer(leftmost_bit(0x80000000), 31);
+  check_integer(leftmost_bit(0xffffffff), 31);
+  for(n = 0; n < 28; ++n) {
+    const uint32_t b = 1 << n, limit = 2 * b;
+    uint32_t v;
+
+    for(v = b; v < limit; ++v)
+      check_integer(leftmost_bit(v), n);
+  }
+}
+
+/*
+Local Variables:
+c-basic-offset:2
+comment-column:40
+fill-column:79
+indent-tabs-mode:nil
+End:
+*/
index c205a5733a18e172ff4576f4e6ac15fda508dd55..30af3a53d0684328bf3656fd21655c0d58ab30ca 100644 (file)
@@ -21,7 +21,7 @@
 
 #include "test.h"
 
-int tests, errors;
+long long tests, errors;
 int fail_first;
 
 void count_error(void) {
@@ -158,7 +158,8 @@ int main(void) {
   test_hash();
   test_url();
   test_regsub();
-  fprintf(stderr,  "%d errors out of %d tests\n", errors, tests);
+  test_bits();
+  fprintf(stderr,  "%lld errors out of %lld tests\n", errors, tests);
   return !!errors;
 }
   
index 2dac63433097827959b5f466fea5c7547d499cdf..32eaa6f87a71e6eff58188e1d61f7979228c92a5 100644 (file)
@@ -70,7 +70,7 @@
 #include "url.h"
 #include "regsub.h"
 
-extern int tests, errors;
+extern long long tests, errors;
 extern int fail_first;
 
 /** @brief Checks that @p expr is nonzero */
@@ -153,6 +153,7 @@ void test_url(void);
 void test_utf8(void);
 void test_words(void);
 void test_wstat(void);
+void test_bits(void);
 
 #endif /* TEST_H */
 
index 9ec328ee0df42bd6d25dba8a6cfc3dfafda576e4..ac2d3eae95c7e7630db605011d9341bc51d3707c 100644 (file)
@@ -102,6 +102,7 @@ int trackdb_delkeydata(DB *db,
 int trackdb_scan(const char *root,
                  int (*callback)(const char *track,
                                  struct kvp *data,
+                                 struct kvp *prefs,
                                  void *u,
                                  DB_TXN *tid),
                  void *u,
@@ -144,6 +145,9 @@ int trackdb_get_global_tid(const char *name,
                            DB_TXN *tid,
                            const char **rp);
 
+char **parsetags(const char *s);
+int tag_intersection(char **a, char **b);
+
 #endif /* TRACKDB_INT_H */
 
 /*
index 875acc5b485517f3c5ff4f3c20443cdae0d263d5..53d7cf311239f056d6c25953ad9e0d5284063edb 100644 (file)
@@ -156,10 +156,6 @@ static pid_t db_deadlock_pid = -1;      /* deadlock manager PID */
 static pid_t rescan_pid = -1;           /* rescanner PID */
 static int initialized, opened;         /* state */
 
-/* tracks matched by required_tags */
-static char **reqtracks;
-static size_t nreqtracks;
-
 /* comparison function for keys */
 static int compare(DB attribute((unused)) *db_,
                   const DBT *a, const DBT *b) {
@@ -849,7 +845,7 @@ static int tagchar(int c) {
 }
 
 /* Parse and de-dupe a tag list.  If S=0 then assumes "". */
-static char **parsetags(const char *s) {
+char **parsetags(const char *s) {
   const char *t;
   struct vector v;
 
@@ -1048,7 +1044,6 @@ int trackdb_notice_tid(const char *track,
   for(n = 0; w[n]; ++n)
     if((err = register_tag(track, w[n], tid)))
       return err;
-  reqtracks = 0;
   /* only store the tracks.db entry if it has changed */
   if(t_changed && (err = trackdb_putdata(trackdb_tracksdb, track, t, tid, 0)))
     return err;
@@ -1106,7 +1101,6 @@ int trackdb_obsolete(const char *track, DB_TXN *tid) {
     if(trackdb_delkeydata(trackdb_tagsdb,
                           w[n], track, tid) == DB_LOCK_DEADLOCK)
       return err;
-  reqtracks = 0;
   /* update tracks.db */
   if(trackdb_delkey(trackdb_tracksdb, track, tid) == DB_LOCK_DEADLOCK)
     return err;
@@ -1468,7 +1462,6 @@ int trackdb_set(const char *track,
             ++newtags;
           }
         }
-        reqtracks = 0;
       }
     }
     err = 0;
@@ -1587,7 +1580,7 @@ int trackdb_listkeys(DB *db, struct vector *v, DB_TXN *tid) {
 }
 
 /* return 1 iff sorted tag lists A and B have at least one member in common */
-static int tag_intersection(char **a, char **b) {
+int tag_intersection(char **a, char **b) {
   int cmp;
 
   /* Same sort of logic as trackdb_set() above */
@@ -1599,176 +1592,93 @@ static int tag_intersection(char **a, char **b) {
   return 0;
 }
 
-/* Check whether a track is suitable for random play.  Returns 0 if it is,
- * DB_NOTFOUND if it is not or DB_LOCK_DEADLOCK if the database gave us
- * that. */
-static int check_suitable(const char *track,
-                          DB_TXN *tid,
-                          char **required_tags,
-                          char **prohibited_tags) {
-  char **track_tags;
-  time_t last, now;
-  struct kvp *p, *t;
-  const char *pick_at_random, *played_time;
-
-  /* don't pick tracks that aren't in any surviving collection (for instance
-   * you've edited the config but the rescan hasn't done its job yet) */
-  if(!find_track_root(track)) {
-    info("found track not in any collection: %s", track);
-    return DB_NOTFOUND;
-  }
-  /* don't pick aliases - only pick the canonical form */
-  if(gettrackdata(track, &t, &p, 0, 0, tid) == DB_LOCK_DEADLOCK)
-    return DB_LOCK_DEADLOCK;
-  if(kvp_get(t, "_alias_for"))
-    return DB_NOTFOUND;
-  /* check that random play is not suppressed for this track */
-  if((pick_at_random = kvp_get(p, "pick_at_random"))
-     && !strcmp(pick_at_random, "0"))
-    return DB_NOTFOUND;
-  /* don't pick a track that's been played in the last 8 hours */
-  if((played_time = kvp_get(p, "played_time"))) {
-    last = atoll(played_time);
-    now = time(0);
-    if(now < last + 8 * 3600)       /* TODO configurable */
-      return DB_NOTFOUND;
-  }
-  track_tags = parsetags(kvp_get(p, "tags"));
-  /* check that no prohibited tag is present for this track */
-  if(prohibited_tags && tag_intersection(track_tags, prohibited_tags))
-    return DB_NOTFOUND;
-  /* check that at least one required tags is present for this track */
-  if(*required_tags && !tag_intersection(track_tags, required_tags))
-    return DB_NOTFOUND;
+static pid_t choose_pid = -1;
+static int choose_fd;
+static random_callback *choose_callback;
+static struct dynstr choose_output;
+static unsigned choose_complete;
+static int choose_status;
+#define CHOOSE_RUNNING 1
+#define CHOOSE_READING 2
+
+static void choose_finished(ev_source *ev, unsigned which) {
+  choose_complete |= which;
+  if(choose_complete != (CHOOSE_RUNNING|CHOOSE_READING))
+    return;
+  choose_pid = -1;
+  if(choose_status == 0 && choose_output.nvec > 0) {
+    dynstr_terminate(&choose_output);
+    choose_callback(ev, xstrdup(choose_output.vec));
+  } else
+    choose_callback(ev, 0);
+}
+
+/** @brief Called when @c disorder-choose terminates */
+static int choose_exited(ev_source *ev,
+                         pid_t attribute((unused)) pid,
+                         int status,
+                         const struct rusage attribute((unused)) *rusage,
+                         void attribute((unused)) *u) {
+  if(status)
+    error(0, "disorder-choose %s", wstat(status));
+  choose_status = status;
+  choose_finished(ev, CHOOSE_RUNNING);
   return 0;
 }
 
-/* attempt to pick a random non-alias track */
-const char *trackdb_random(int tries) {
-  DBT key, data;
-  DB_BTREE_STAT *sp;
-  int err, n;
-  DB_TXN *tid;
-  const char *track, *candidate;
-  db_recno_t r;
-  const char *tags;
-  char **required_tags, **prohibited_tags, **tp;
-  hash *h;
-  DBC *c = 0;
+/** @brief Called with data from @c disorder-choose pipe */
+static int choose_readable(ev_source *ev,
+                           ev_reader *reader,
+                           void *ptr,
+                           size_t bytes,
+                           int eof,
+                           void attribute((unused)) *u) {
+  dynstr_append_bytes(&choose_output, ptr, bytes);
+  ev_reader_consume(reader, bytes);
+  if(eof)
+    choose_finished(ev, CHOOSE_READING);
+  return 0;
+}
 
-  for(;;) {
-    tid = trackdb_begin_transaction();
-    if((err = trackdb_get_global_tid("required-tags", tid, &tags)))
-      goto fail;
-    required_tags = parsetags(tags);
-    if((err = trackdb_get_global_tid("prohibited-tags", tid, &tags)))
-      goto fail;
-    prohibited_tags = parsetags(tags);
-    track = 0;
-    if(*required_tags) {
-      /* Bung all the suitable tracks into a hash and convert to a list of keys
-       * (to eliminate duplicates).  We cache this list since it is possible
-       * that it will be very large. */
-      if(!reqtracks) {
-        h = hash_new(0);
-        for(tp = required_tags; *tp; ++tp) {
-          c = trackdb_opencursor(trackdb_tagsdb, tid);
-          memset(&key, 0, sizeof key);
-          key.data = *tp;
-          key.size = strlen(*tp);
-          n = 0;
-          err = c->c_get(c, &key, prepare_data(&data), DB_SET);
-          while(err == 0) {
-            hash_add(h, xstrndup(data.data, data.size), 0,
-                     HASH_INSERT_OR_REPLACE);
-            ++n;
-            err = c->c_get(c, &key, prepare_data(&data), DB_NEXT_DUP);
-          }
-          switch(err) {
-          case 0:
-          case DB_NOTFOUND:
-            break;
-          case DB_LOCK_DEADLOCK:
-            goto fail;
-          default:
-            fatal(0, "error querying tags.db: %s", db_strerror(err));
-          }
-          trackdb_closecursor(c);
-          c = 0;
-          if(!n)
-            error(0, "required tag %s does not match any tracks", *tp);
-        }
-        nreqtracks = hash_count(h);
-        reqtracks = hash_keys(h);
-      }
-      while(nreqtracks && !track && tries-- > 0) {
-        r = (rand() * (double)nreqtracks / (RAND_MAX + 1.0));
-        candidate = reqtracks[r];
-        switch(check_suitable(candidate, tid,
-                              required_tags, prohibited_tags)) {
-        case 0:
-          track = candidate;
-          break;
-        case DB_NOTFOUND:
-          break;
-        case DB_LOCK_DEADLOCK:
-          goto fail;
-        }
-      }
-    } else {
-      /* No required tags.  We pick random record numbers in the database
-       * instead. */
-      switch(err = trackdb_tracksdb->stat(trackdb_tracksdb, tid, &sp, 0)) {
-      case 0:
-        break;
-      case DB_LOCK_DEADLOCK:
-        error(0, "error querying tracks.db: %s", db_strerror(err));
-        goto fail;
-      default:
-        fatal(0, "error querying tracks.db: %s", db_strerror(err));
-      }
-      if(!sp->bt_nkeys)
-        error(0, "cannot pick tracks at random from an empty database");
-      while(sp->bt_nkeys && !track && tries-- > 0) {
-        /* record numbers count from 1 upwards */
-        r = 1 + (rand() * (double)sp->bt_nkeys / (RAND_MAX + 1.0));
-        memset(&key, sizeof key, 0);
-        key.flags = DB_DBT_MALLOC;
-        key.size = sizeof r;
-        key.data = &r;
-        switch(err = trackdb_tracksdb->get(trackdb_tracksdb, tid, &key, prepare_data(&data),
-                                           DB_SET_RECNO)) {
-        case 0:
-          break;
-        case DB_LOCK_DEADLOCK:
-          error(0, "error querying tracks.db: %s", db_strerror(err));
-          goto fail;
-        default:
-          fatal(0, "error querying tracks.db: %s", db_strerror(err));
-        }
-        candidate = xstrndup(key.data, key.size);
-        switch(check_suitable(candidate, tid,
-                              required_tags, prohibited_tags)) {
-        case 0:
-          track = candidate;
-          break;
-        case DB_NOTFOUND:
-          break;
-        case DB_LOCK_DEADLOCK:
-          goto fail;
-        }
-      }
-    }
-    break;
-fail:
-    trackdb_closecursor(c);
-    c = 0;
-    trackdb_abort_transaction(tid);
-  }
-  trackdb_commit_transaction(tid);
-  if(!track)
-    error(0, "could not pick a random track");
-  return track;
+static int choose_read_error(ev_source *ev,
+                             int errno_value,
+                             void attribute((unused)) *u) {
+  error(errno_value, "error reading disorder-choose pipe");
+  choose_finished(ev, CHOOSE_READING);
+  return 0;
+}
+
+/** @brief Request a random track
+ * @param ev Event source
+ * @param callback Called with random track or NULL
+ * @return 0 if a request was initiated, else -1
+ *
+ * Initiates a random track choice.  @p callback will later be called back with
+ * the choice (or NULL on error).  If a choice is already underway then -1 is
+ * returned and there will be no additional callback.
+ *
+ * The caller shouldn't assume that the track returned actually exists (it
+ * might be removed between the choice and the callback, or between being added
+ * to the queue and being played).
+ */
+int trackdb_request_random(ev_source *ev,
+                           random_callback *callback) {
+  int p[2];
+  
+  if(choose_pid != -1)
+    return -1;                          /* don't run concurrent chooses */
+  xpipe(p);
+  cloexec(p[0]);
+  choose_pid = subprogram(ev, p[1], "disorder-choose", (char *)0);
+  choose_fd = p[0];
+  xclose(p[1]);
+  choose_callback = callback;
+  choose_output.nvec = 0;
+  choose_complete = 0;
+  ev_reader_new(ev, p[0], choose_readable, choose_read_error, 0,
+                "disorder-choose reader"); /* owns p[0] */
+  ev_child(ev, choose_pid, 0, choose_exited, 0); /* owns the subprocess */
+  return 0;
 }
 
 /* get a track name given the prefs.  Set *used_db to 1 if we got the answer
@@ -2101,15 +2011,16 @@ char **trackdb_search(char **wordlist, int nwordlist, int *ntracks) {
 int trackdb_scan(const char *root,
                  int (*callback)(const char *track,
                                  struct kvp *data,
+                                 struct kvp *prefs,
                                  void *u,
                                  DB_TXN *tid),
                  void *u,
                  DB_TXN *tid) {
   DBC *cursor;
-  DBT k, d;
+  DBT k, d, pd;
   const size_t root_len = root ? strlen(root) : 0;
   int err, cberr;
-  struct kvp *data;
+  struct kvp *data, *prefs;
   const char *track;
 
   cursor = trackdb_opencursor(trackdb_tracksdb, tid);
@@ -2129,10 +2040,33 @@ int trackdb_scan(const char *root,
       data = kvp_urldecode(d.data, d.size);
       if(kvp_get(data, "_path")) {
         track = xstrndup(k.data, k.size);
+        /* TODO: trackdb_prefsdb is currently a DB_HASH.  This means we have to
+         * do a lookup for every single track.  In fact this is quite quick:
+         * with around 10,000 tracks a complete scan is around 0.3s on my
+         * 2.2GHz Athlon.  However, if it were a DB_BTREE, we could do the same
+         * linear walk as we already do over trackdb_tracksdb, and probably get
+         * even higher performance.  That would require upgrade logic to
+         * translate old databases though.
+         */
+        switch(err = trackdb_prefsdb->get(trackdb_prefsdb, tid, &k,
+                                          prepare_data(&pd), 0)) {
+        case 0:
+          prefs = kvp_urldecode(pd.data, pd.size);
+          break;
+        case DB_NOTFOUND:
+          prefs = 0;
+          break;
+        case DB_LOCK_DEADLOCK:
+          error(0, "getting prefs: %s", db_strerror(err));
+          trackdb_closecursor(cursor);
+          return err;
+        default:
+          fatal(0, "getting prefs: %s", db_strerror(err));
+        }
         /* Advance to the next track before the callback so that the callback
          * may safely delete the track */
         err = cursor->c_get(cursor, &k, &d, DB_NEXT);
-        if((cberr = callback(track, data, u, tid))) {
+        if((cberr = callback(track, data, prefs, u, tid))) {
           err = cberr;
           break;
         }
@@ -2239,8 +2173,6 @@ void trackdb_set_global(const char *name,
          who ? who : "-");
     eventlog("state", state ? "enable_random" : "disable_random", (char *)0);
   }
-  if(!strcmp(name, "required-tags"))
-    reqtracks = 0;
 }
 
 int trackdb_set_global_tid(const char *name,
@@ -2708,10 +2640,13 @@ int trackdb_edituserinfo(const char *user,
       return -1;
     }
   } else if(!strcmp(key, "email")) {
-    if(!strchr(value, '@')) {
-      error(0, "invalid email address '%s' for user '%s'", user, value);
-      return -1;
-    }
+    if(*value) {
+      if(!strchr(value, '@')) {
+        error(0, "invalid email address '%s' for user '%s'", user, value);
+        return -1;
+      }
+    } else
+      value = 0;                        /* no email -> remove key */
   } else if(!strcmp(key, "created")) {
     error(0, "cannot change creation date for user '%s'", user);
     return -1;
index e100f2594bd82ed4c6d4d4a8c6db41f4482fa54e..f372f4cf0586e9fbe57e6fa3138a157d10d5eacc 100644 (file)
@@ -173,6 +173,11 @@ int trackdb_confirm(const char *user, const char *confirmation,
                     rights_type *rightsp);
 int trackdb_readable(void);
 
+typedef void random_callback(struct ev_source *ev,
+                             const char *track);
+int trackdb_request_random(struct ev_source *ev,
+                           random_callback *callback);
+
 #endif /* TRACKDB_H */
 
 /*
index 5f9d2fef712302d0eb8f396a7c9efd4257ce1664..14aabdbe2693c995f591230af699bbf5d724228b 100755 (executable)
@@ -26,8 +26,6 @@ make check
 make dist-bzip2
 d=$(make echo-distdir)
 cp $d.tar.bz2 $HOME/work/web/disorder
-cp CHANGES $HOME/work/web/disorder/CHANGES.txt
-cp README $HOME/work/web/disorder/README.txt
 cd doc
 for f in *.[1-9].html; do
   echo $f
index 5bbc07bc194bf17986c6f508b87d19f3acdae6f0..fa966dda477eddc870a0005e26a6261101ddc15b 100755 (executable)
@@ -53,7 +53,7 @@ if $stdhead; then
 fi
 printf "   <pre class=manpage>"
 # this is kind of painful using only BREs
-nroff -man "$1" | ${GNUSED} \
+nroff -Tascii -man "$1" | ${GNUSED} \
                       '1d;$d;
                        1,/./{/^$/d};
                        s/&/\&amp;/g;
index bc58b8486ea965a508786067334c43eac3d10b57..78497a542b94542dcd89a9c1df87c4b297ffb3dd 100644 (file)
@@ -20,7 +20,7 @@
 
 sbin_PROGRAMS=disorderd disorder-deadlock disorder-rescan disorder-dump \
              disorder-speaker disorder-decode disorder-normalize \
-             disorder-stats disorder-dbupgrade
+             disorder-stats disorder-dbupgrade disorder-choose
 noinst_PROGRAMS=disorder.cgi trackname
 
 AM_CPPFLAGS=-I${top_srcdir}/lib -I../lib
@@ -74,6 +74,16 @@ disorder_rescan_LDADD=$(LIBOBJS) ../lib/libdisorder.a \
 disorder_rescan_LDFLAGS=-export-dynamic
 disorder_rescan_DEPENDENCIES=../lib/libdisorder.a
 
+disorder_choose_SOURCES=choose.c                        \
+       server-queue.c server-queue.h                   \
+        api.c api-server.c                              \
+        exports.c                                      \
+       ../lib/memgc.c
+disorder_choose_LDADD=$(LIBOBJS) ../lib/libdisorder.a   \
+       $(LIBDB) $(LIBGC) $(LIBPCRE) $(LIBGCRYPT)
+disorder_choose_LDFLAGS=-export-dynamic
+disorder_choose_DEPENDENCIES=../lib/libdisorder.a
+
 disorder_stats_SOURCES=stats.c
 disorder_stats_LDADD=$(LIBOBJS) ../lib/libdisorder.a \
        $(LIBDB) $(LIBPCRE) $(LIBICONV) $(LIBGCRYPT)
index b1267f0f0f7ac254e8fb0313925ad8c1a9d4b6a3..bc8144e5b8e551e5bb52242bbb14dea9aa2d2f16 100644 (file)
@@ -49,10 +49,6 @@ int disorder_track_set_data(const char *track,
   return trackdb_set(track, key, value);
 }
 
-const char *disorder_track_random(void)  {
-  return trackdb_random(16);
-}
-
 /*
 Local Variables:
 c-basic-offset:2
diff --git a/server/choose.c b/server/choose.c
new file mode 100644 (file)
index 0000000..06c9601
--- /dev/null
@@ -0,0 +1,305 @@
+/*
+ * This file is part of DisOrder 
+ * Copyright (C) 2008 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
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+ * USA
+ */
+/** @file choose.c
+ * @brief Random track chooser
+ *
+ * Picks a track at random and writes it to standard output.  If for
+ * any reason no track can be picked - even a trivial reason like a
+ * deadlock - it just exits and expects the server to try again.
+ */
+
+#include <config.h>
+#include "types.h"
+
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <db.h>
+#include <locale.h>
+#include <errno.h>
+#include <sys/types.h>
+#include <unistd.h>
+#include <pcre.h>
+#include <string.h>
+#include <fcntl.h>
+#include <syslog.h>
+#include <time.h>
+
+#include "configuration.h"
+#include "log.h"
+#include "defs.h"
+#include "mem.h"
+#include "kvp.h"
+#include "syscalls.h"
+#include "printf.h"
+#include "trackdb.h"
+#include "trackdb-int.h"
+#include "version.h"
+#include "trackname.h"
+#include "queue.h"
+#include "server-queue.h"
+
+static DB_TXN *global_tid;
+
+static const struct option options[] = {
+  { "help", no_argument, 0, 'h' },
+  { "version", no_argument, 0, 'V' },
+  { "config", required_argument, 0, 'c' },
+  { "debug", no_argument, 0, 'd' },
+  { "no-debug", no_argument, 0, 'D' },
+  { "syslog", no_argument, 0, 's' },
+  { "no-syslog", no_argument, 0, 'S' },
+  { 0, 0, 0, 0 }
+};
+
+/* display usage message and terminate */
+static void help(void) {
+  xprintf("Usage:\n"
+         "  disorder-choose [OPTIONS]\n"
+         "Options:\n"
+         "  --help, -h              Display usage message\n"
+         "  --version, -V           Display version number\n"
+         "  --config PATH, -c PATH  Set configuration file\n"
+         "  --debug, -d             Turn on debugging\n"
+          "  --[no-]syslog           Enable/disable logging to syslog\n"
+          "\n"
+          "Track choose for DisOrder.  Not intended to be run\n"
+          "directly.\n");
+  xfclose(stdout);
+  exit(0);
+}
+
+/** @brief Weighted track record */
+struct weighted_track {
+  /** @brief Next track in the list */
+  struct weighted_track *next;
+  /** @brief Track name */
+  const char *track;
+  /** @brief Weight for this track (always positive) */
+  unsigned long weight;
+};
+
+/** @brief List of tracks with nonzero weight */
+static struct weighted_track *tracks;
+
+/** @brief Sum of all weights */
+static unsigned long long total_weight;
+
+/** @brief Count of tracks */
+static long ntracks;
+
+static char **required_tags;
+static char **prohibited_tags;
+
+static int queue_contains(const struct queue_entry *head,
+                          const char *track) {
+  const struct queue_entry *q;
+
+  for(q = head->next; q != head; q = q->next)
+    if(!strcmp(q->track, track))
+      return 1;
+  return 0;
+}
+
+/** @brief Compute the weight of a track
+ * @param track Track name (UTF-8)
+ * @param data Track data
+ * @param prefs Track preferences
+ * @return Track weight (non-negative)
+ *
+ * Tracks to be excluded entirely are given a weight of 0.
+ */
+static unsigned long compute_weight(const char *track,
+                                    struct kvp *data,
+                                    struct kvp *prefs) {
+  const char *s;
+  char **track_tags;
+  time_t last, now;
+
+  /* Reject tracks not in any collection (race between edit config and
+   * rescan) */
+  if(!find_track_root(track)) {
+    info("found track not in any collection: %s", track);
+    return 0;
+  }
+
+  /* Reject aliases to avoid giving aliased tracks extra weight */
+  if(kvp_get(data, "_alias_for"))
+    return 0;
+  
+  /* Reject tracks with random play disabled */
+  if((s = kvp_get(prefs, "pick_at_random"))
+     && !strcmp(s, "0"))
+    return 0;
+
+  /* Reject tracks played within the last 8 hours */
+  if((s = kvp_get(prefs, "played_time"))) {
+    last = atoll(s);
+    now = time(0);
+    if(now < last + config->replay_min)
+      return 0;
+  }
+
+  /* Reject tracks currently in the queue or in the recent list */
+  if(queue_contains(&qhead, track)
+     || queue_contains(&phead, track))
+    return 0;
+
+  /* We'll need tags for a number of things */
+  track_tags = parsetags(kvp_get(prefs, "tags"));
+
+  /* Reject tracks with prohibited tags */
+  if(prohibited_tags && tag_intersection(track_tags, prohibited_tags))
+    return 0;
+
+  /* Reject tracks that lack required tags */
+  if(*required_tags && !tag_intersection(track_tags, required_tags))
+    return 0;
+
+  /* Use the configured weight if available */
+  if((s = kvp_get(prefs, "weight"))) {
+    long n;
+    errno = 0;
+
+    n = strtol(s, 0, 10);
+    if((errno == 0 || errno == ERANGE) && n >= 0)
+      return n;
+  }
+  
+  return 90000;
+}
+
+/** @brief Called for each track */
+static int collect_tracks_callback(const char *track,
+                                  struct kvp *data,
+                                   struct kvp *prefs,
+                                  void attribute((unused)) *u,
+                                  DB_TXN attribute((unused)) *tid) {
+  unsigned long weight = compute_weight(track, data, prefs);
+
+  if(weight) {
+    struct weighted_track *const t = xmalloc(sizeof *t);
+
+    /* Clamp weight so that we can fit in billions of tracks when we do
+     * arithmetic in long long */
+    if(weight > 0x7fffffff)
+      weight = 0x7fffffff;
+    t->next = tracks;
+    t->track = track;
+    t->weight = weight;
+    tracks = t;
+    total_weight += weight;
+    ++ntracks;
+  }
+  return 0;
+}
+
+/** @brief Pick a random integer uniformly from [0, limit) */
+static unsigned long long pick_weight(unsigned long long limit) {
+  unsigned long long n;
+  static int fd = -1;
+  int r;
+
+  if(fd < 0) {
+    if((fd = open("/dev/urandom", O_RDONLY)) < 0)
+      fatal(errno, "opening /dev/urandom");
+  }
+  if((r = read(fd, &n, sizeof n)) < 0)
+    fatal(errno, "reading /dev/urandom");
+  if((size_t)r < sizeof n)
+    fatal(0, "short read from /dev/urandom");
+  return n % limit;
+}
+
+/** @brief Pick a track at random and write it to stdout */
+static void pick_track(void) {
+  long long w;
+  struct weighted_track *t;
+
+  w = pick_weight(total_weight);
+  t = tracks;
+  while(t && w >= t->weight) {
+    w -= t->weight;
+    t = t->next;
+  }
+  if(!t)
+    fatal(0, "ran out of tracks but %lld weighting left", w);
+  xprintf("%s", t->track);
+}
+
+int main(int argc, char **argv) {
+  int n, logsyslog = !isatty(2), err;
+  const char *tags;
+  
+  set_progname(argv);
+  mem_init();
+  if(!setlocale(LC_CTYPE, "")) fatal(errno, "error calling setlocale");
+  while((n = getopt_long(argc, argv, "hVc:dDSs", options, 0)) >= 0) {
+    switch(n) {
+    case 'h': help();
+    case 'V': version("disorder-choose");
+    case 'c': configfile = optarg; break;
+    case 'd': debugging = 1; break;
+    case 'D': debugging = 0; break;
+    case 'S': logsyslog = 0; break;
+    case 's': logsyslog = 1; break;
+    default: fatal(0, "invalid option");
+    }
+  }
+  if(logsyslog) {
+    openlog(progname, LOG_PID, LOG_DAEMON);
+    log_default = &log_syslog;
+  }
+  if(config_read(0)) fatal(0, "cannot read configuration");
+  /* Find out current queue/recent list */
+  queue_read();
+  recent_read();
+  /* Generate the candidate track list */
+  trackdb_init(TRACKDB_NO_RECOVER);
+  trackdb_open(TRACKDB_NO_UPGRADE|TRACKDB_READ_ONLY);
+  global_tid = trackdb_begin_transaction();
+  if((err = trackdb_get_global_tid("required-tags", global_tid, &tags)))
+    fatal(0, "error getting required-tags: %s", db_strerror(err));
+  required_tags = parsetags(tags);
+  if((err = trackdb_get_global_tid("prohibited-tags", global_tid, &tags)))
+    fatal(0, "error getting prohibited-tags: %s", db_strerror(err));
+  prohibited_tags = parsetags(tags);
+  if(trackdb_scan(0, collect_tracks_callback, 0, global_tid))
+    exit(1);
+  trackdb_commit_transaction(global_tid);
+  trackdb_close();
+  trackdb_deinit();
+  //info("ntracks=%ld total_weight=%lld", ntracks, total_weight);
+  if(!total_weight)
+    fatal(0, "no tracks match random choice criteria");
+  /* Pick a track */
+  pick_track();
+  xfclose(stdout);
+  return 0;
+}
+
+/*
+Local Variables:
+c-basic-offset:2
+comment-column:40
+fill-column:79
+indent-tabs-mode:nil
+End:
+*/
index 19e25e0b97aa0a93b8708e0700c5e974a50aed51..33630faa85ea2e037bc3fb73ae9d29c835c2aa8b 100644 (file)
@@ -426,8 +426,18 @@ static void process_prefs(dcgi_state *ds, int numfile) {
       disorder_unset(ds->g->client, file, "pick_at_random");
     else
       disorder_set(ds->g->client, file, "pick_at_random", "0");
-    if((value = numbered_arg("tags", numfile)))
-      disorder_set(ds->g->client, file, "tags", value);
+    if((value = numbered_arg("tags", numfile))) {
+      if(!*value)
+       disorder_unset(ds->g->client, file, "tags");
+      else
+       disorder_set(ds->g->client, file, "tags", value);
+    }
+    if((value = numbered_arg("weight", numfile))) {
+      if(!*value || !strcmp(value, "90000"))
+       disorder_unset(ds->g->client, file, "weight");
+      else
+       disorder_set(ds->g->client, file, "weight", value);
+    }
   } else if((name = cgi_get("name"))) {
     /* Raw preferences.  Not well supported in the templates at the moment. */
     value = cgi_get("value");
index 2fd317d05b9d54ca99ab756c5dd1389f81e47616..01d4efe7bac40d34419bbd452c77c5f6b2fa8e4e 100644 (file)
 
 static ev_source *ev;
 
-static void rescan_after(long offset);
-static void dbgc_after(long offset);
-static void volumecheck_after(long offset);
-
 static const struct option options[] = {
   { "help", no_argument, 0, 'h' },
   { "version", no_argument, 0, 'V' },
@@ -94,6 +90,8 @@ static void help(void) {
   exit(0);
 }
 
+/* signals ------------------------------------------------------------------ */
+
 /* SIGHUP callback */
 static int handle_sighup(ev_source attribute((unused)) *ev_,
                         int attribute((unused)) sig,
@@ -119,41 +117,57 @@ static int handle_sigterm(ev_source attribute((unused)) *ev_,
   quit(ev);
 }
 
-static int rescan_again(ev_source *ev_,
-                       const struct timeval attribute((unused)) *now,
-                       void attribute((unused)) *u) {
-  trackdb_rescan(ev_, 1/*check*/);
-  rescan_after(86400);
-  return 0;
-}
+/* periodic actions --------------------------------------------------------- */
+
+struct periodic_data {
+  void (*callback)(ev_source *);
+  int period;
+};
 
-static void rescan_after(long offset) {
+static int periodic_callback(ev_source *ev_,
+                            const struct timeval attribute((unused)) *now,
+                            void *u) {
   struct timeval w;
+  struct periodic_data *const pd = u;
 
+  pd->callback(ev_);
   gettimeofday(&w, 0);
-  w.tv_sec += offset;
-  ev_timeout(ev, 0, &w, rescan_again, 0);
-}
-
-static int dbgc_again(ev_source attribute((unused)) *ev_,
-                     const struct timeval attribute((unused)) *now,
-                     void attribute((unused)) *u) {
-  trackdb_gc();
-  dbgc_after(60);
+  w.tv_sec += pd->period;
+  ev_timeout(ev, 0, &w, periodic_callback, pd);
   return 0;
 }
 
-static void dbgc_after(long offset) {
+/** @brief Create a periodic action
+ * @param ev Event loop
+ * @param callback Callback function
+ * @param period Interval between calls in seconds
+ * @param immediate If true, call @p callback straight away
+ */
+static void create_periodic(ev_source *ev_,
+                           void (*callback)(ev_source *),
+                           int period,
+                           int immediate) {
   struct timeval w;
+  struct periodic_data *const pd = xmalloc(sizeof *pd);
 
+  pd->callback = callback;
+  pd->period = period;
+  if(immediate)
+    callback(ev_);
   gettimeofday(&w, 0);
-  w.tv_sec += offset;
-  ev_timeout(ev, 0, &w, dbgc_again, 0);
+  w.tv_sec += period;
+  ev_timeout(ev_, 0, &w, periodic_callback, pd);
 }
 
-static int volumecheck_again(ev_source attribute((unused)) *ev_,
-                            const struct timeval attribute((unused)) *now,
-                            void attribute((unused)) *u) {
+static void periodic_rescan(ev_source *ev_) {
+  trackdb_rescan(ev_, 1/*check*/);
+}
+
+static void periodic_database_gc(ev_source attribute((unused)) *ev_) {
+  trackdb_gc();
+}
+
+static void periodic_volume_check(ev_source attribute((unused)) *ev_) {
   int l, r;
   char lb[32], rb[32];
 
@@ -166,16 +180,14 @@ static int volumecheck_again(ev_source attribute((unused)) *ev_,
       eventlog("volume", lb, rb, (char *)0);
     }
   }
-  volumecheck_after(60);
-  return 0;
 }
 
-static void volumecheck_after(long offset) {
-  struct timeval w;
+static void periodic_play_check(ev_source *ev_) {
+  play(ev_);
+}
 
-  gettimeofday(&w, 0);
-  w.tv_sec += offset;
-  ev_timeout(ev, 0, &w, volumecheck_again, 0);
+static void periodic_add_random(ev_source *ev_) {
+  add_random_track(ev_);
 }
 
 /* We fix the path to include the bindir and sbindir we were installed into */
@@ -284,17 +296,16 @@ int main(int argc, char **argv) {
   if(ev_signal(ev, SIGTERM, handle_sigterm, 0)) fatal(0, "ev_signal failed");
   /* ignore SIGPIPE */
   signal(SIGPIPE, SIG_IGN);
-  /* Start a rescan straight away */
-  trackdb_rescan(ev, 1/*check*/);
-  /* We'll rescan again after a day */
-  rescan_after(86400);
-  /* periodically tidy up the database */
-  dbgc_after(60);
-  /* periodically check the volume */
-  volumecheck_again(0, 0, 0);
-  /* set initial state */
-  add_random_track();
-  play(ev);
+  /* Rescan immediately and then daily */
+  create_periodic(ev, periodic_rescan, 86400, 1/*immediate*/);
+  /* Tidy up the database once a minute */
+  create_periodic(ev, periodic_database_gc, 60, 0);
+  /* Check the volume immediately and then once a minute */
+  create_periodic(ev, periodic_volume_check, 60, 1);
+  /* Check for a playable track once a second */
+  create_periodic(ev, periodic_play_check, 1, 0);
+  /* Try adding a random track immediately and once every ten seconds */
+  create_periodic(ev, periodic_add_random, 10, 1);
   /* enter the event loop */
   n = ev_run(ev);
   /* if we exit the event loop, something must have gone wrong */
index 2b6422de6b7f4d98fac0402558dffd8f275f7654..df9158b4fb0eab63c5d124d7dfc8ba11b884d284 100644 (file)
@@ -174,25 +174,6 @@ void speaker_reload(void) {
   speaker_send(speaker_fd, &sm);
 }
 
-/* timeout for play retry */
-static int play_again(ev_source *ev,
-                     const struct timeval attribute((unused)) *now,
-                     void attribute((unused)) *u) {
-  D(("play_again"));
-  play(ev);
-  return 0;
-}
-
-/* try calling play() again after @offset@ seconds */
-static void retry_play(ev_source *ev, int offset) {
-  struct timeval w;
-
-  D(("retry_play(%d)", offset));
-  gettimeofday(&w, 0);
-  w.tv_sec += offset;
-  ev_timeout(ev, 0, &w, play_again, 0);
-}
-
 /* Called when the currently playing track finishes playing.  This
  * might be because the player finished or because the speaker process
  * told us so. */
@@ -219,7 +200,10 @@ static void finished(ev_source *ev) {
   recent_write();
   forget_player_pid(playing->id);
   playing = 0;
-  if(ev) retry_play(ev, config->gap);
+  /* Try to play something else */
+  /* TODO re-support config->gap? */
+  if(ev)
+    play(ev);
 }
 
 /* Called when a player terminates. */
@@ -531,34 +515,40 @@ void abandon(ev_source attribute((unused)) *ev,
   speaker_send(speaker_fd, &sm);
 }
 
-int add_random_track(void) {
+/** @brief Called with a new random track
+ * @param track Track name
+ */
+static void chosen_random_track(ev_source *ev,
+                               const char *track) {
+  struct queue_entry *q;
+
+  if(!track)
+    return;
+  /* Add the track to the queue */
+  q = queue_add(track, 0, WHERE_END);
+  q->state = playing_random;
+  D(("picked %p (%s) at random", (void *)q, q->track));
+  queue_write();
+  /* Maybe a track can now be played */
+  play(ev);
+}
+
+/** @brief Maybe add a randomly chosen track
+ * @param ev Event loop
+ */
+void add_random_track(ev_source *ev) {
   struct queue_entry *q;
-  const char *p;
   long qlen = 0;
-  int rc = 0;
 
   /* If random play is not enabled then do nothing. */
   if(shutting_down || !random_is_enabled())
-    return 0;
+    return;
   /* Count how big the queue is */
   for(q = qhead.next; q != &qhead; q = q->next)
     ++qlen;
-  /* Add random tracks until the queue is at the right size */
-  while(qlen < config->queue_pad) {
-    /* Try to pick a random track */
-    if(!(p = trackdb_random(16))) {
-      rc = -1;
-      break;
-    }
-    /* Add it to the end of the queue. */
-    q = queue_add(p, 0, WHERE_END);
-    q->state = playing_random;
-    D(("picked %p (%s) at random", (void *)q, q->track));
-    ++qlen;
-  }
-  /* Commit the queue */
-  queue_write();
-  return rc;
+  /* If it's smaller than the desired size then add a track */
+  if(qlen < config->queue_pad)
+    trackdb_request_random(ev, chosen_random_track);
 }
 
 /* try to play a track */
@@ -568,17 +558,15 @@ void play(ev_source *ev) {
 
   D(("play playing=%p", (void *)playing));
   if(shutting_down || playing || !playing_is_enabled()) return;
-  /* If the queue is empty then add a random track. */
+  /* See if there's anything to play */
   if(qhead.next == &qhead) {
-    if(!random_enabled)
-      return;
-    if(add_random_track()) {
-      /* On error, try again in 10s. */
-      retry_play(ev, 10);
-      return;
-    }
-    /* Now there must be at least one track in the queue. */
+    /* Queue is empty.  We could just wait around since there are periodic
+     * attempts to add a random track anyway.  However they are rarer than
+     * attempts to force a track so we initiate one now. */
+    add_random_track(ev);
+    return;
   }
+  /* There must be at least one track in the queue. */
   q = qhead.next;
   /* If random play is disabled but the track is a random one then don't play
    * it.  play() will be called again when random play is re-enabled. */
@@ -593,19 +581,11 @@ void play(ev_source *ev) {
       queue_played(q);
       recent_write();
     }
-    if(qhead.next == &qhead)
-      /* Queue is empty, wait a bit before trying something else (so we don't
-       * sit there looping madly in the presence of persistent problem).  Note
-       * that we might not reliably get a random track lookahead in this case,
-       * but if we get here then really there are bigger problems. */
-      retry_play(ev, 1);
-    else
-      /* More in queue, try again now. */
-      play(ev);
+    /* Oh well, try the next one */
+    play(ev);
     break;
   case START_SOFTFAIL:
-    /* Try same track again in a bit. */
-    retry_play(ev, 10);
+    /* We'll try the same track again shortly. */
     break;
   case START_OK:
     if(q == qhead.next) {
@@ -620,7 +600,7 @@ void play(ev_source *ev) {
             playing->submitter ? playing->submitter : (const char *)0,
             (const char *)0);
     /* Maybe add a random track. */
-    add_random_track();
+    add_random_track(ev);
     /* If there is another track in the queue prepare it now.  This could
      * potentially be a just-added random track. */
     if(qhead.next != &qhead)
@@ -638,7 +618,7 @@ int playing_is_enabled(void) {
 void enable_playing(const char *who, ev_source *ev) {
   trackdb_set_global("playing", "yes", who);
   /* Add a random track if necessary. */
-  add_random_track();
+  add_random_track(ev);
   play(ev);
 }
 
@@ -654,7 +634,7 @@ int random_is_enabled(void) {
 
 void enable_random(const char *who, ev_source *ev) {
   trackdb_set_global("random-play", "yes", who);
-  add_random_track();
+  add_random_track(ev);
   play(ev);
 }
 
index c40231241e48816fb84bb23a3f76772d97e1441f..aff474ce71d8c0d2bad64e04b5cd6135c5671efa 100644 (file)
@@ -75,9 +75,8 @@ void abandon(ev_source *ev,
             struct queue_entry *q);
 /* Abandon a possibly-prepared track. */
 
-int add_random_track(void);
-/* If random play is enabled then try to add a track to the queue.  On success
- * (including deliberartely doing nothing) return 0.  On error return -1. */
+void add_random_track(ev_source *ev);
+/* If random play is enabled then try to add a track to the queue. */
 
 #endif /* PLAY_H */
 
index c715e9190c5987eb83384c594c8ff6995f46256b..2d390f815e00468e3f0049783aebf95853a00b6c 100644 (file)
@@ -207,6 +207,7 @@ struct recheck_track {
 /* called for each non-alias track */
 static int recheck_list_callback(const char *track,
                                  struct kvp attribute((unused)) *data,
+                                 struct kvp attribute((unused)) *prefs,
                                  void *u,
                                  DB_TXN attribute((unused)) *tid) {
   struct recheck_state *cs = u;
index 67b1fdcb4b95b21340c2375f99b495042cecb1d8..e03ff9c3ea1a4c5b935b7f3723eed704ced337ec 100644 (file)
@@ -272,9 +272,8 @@ static int c_remove(struct conn *c, char **vec,
   queue_remove(q, c->who);
   /* De-prepare the track. */
   abandon(c->ev, q);
-  /* If we removed a random track then add another one. */
-  if(q->state == playing_random)
-    add_random_track();
+  /* See about adding a new random track */
+  add_random_track(c->ev);
   /* Prepare whatever the next head track is. */
   if(qhead.next != &qhead)
     prepare(c->ev, qhead.next);
@@ -1174,7 +1173,7 @@ static int c_userinfo(struct conn *c,
   const char *value;
 
   /* RIGHT_ADMIN allows anything; otherwise you can only get your own email
-   * address and righst list. */
+   * address and rights list. */
   if((c->rights & RIGHT_ADMIN)
      || (!strcmp(c->who, vec[0])
         && (!strcmp(vec[1], "email")
index 3a5511907cfc5b7630e9665b9f338d2c73236d0e..8e298efbbd387dc019aa65115bc67ccb819ebb47 100644 (file)
@@ -212,14 +212,18 @@ USA
     rights, you may not be able to edit track preferences.</p>
     
     <p>The form can be used to
-    edit artist, album and title fields for the track as displayed, or
-    to set the tags for a track, or to enable or disable random play
+    edit artist, album and title fields for the track as displayed; or
+    to set the tags or weight for a track; or to enable or disable random play
     for the track.</p>
 
     <p>Tags are separated by commas and can contain any other printing
     characters (including spaces).  Leading and trailing spaces are
     not significant.</p>
 
+    <p>Weights determine how likely a track is to be picked at
+    random.  Tracks with higher weights are more likely to be picked.
+    The default weight is 90000 and the maximum is 2147483647.</p>
+
     <p>By default, any track can be picked for random play.  The check
     box at the bottom can be used to selectivel enable or disable it
     for individual tracks.</p>
index 82de2d934fb1db162c9bbcc06e58e856cd26854d..55d661a538ed7ae5c5624cf26a404b67ae4cf12b 100644 (file)
@@ -131,6 +131,7 @@ label       prefs.value             Value
 # Legend for prefs controls that don't correspond to a heading
 label  prefs.random            "Random play"
 label  prefs.tags              "Tags"
+label  prefs.weight            "Weight"
 
 # <TITLE> for help page
 label  help.title              "DisOrder Help"
index 35dc4b6ddc4e7cf044bac7a465123a1ede7e57eb..5e88890148c5f0d7b74e2b38fc29a8078cc6fbfc 100644 (file)
@@ -56,6 +56,10 @@ USA
        <td class="prefs_value"><input size=40 type=text name="@index@_tags" value="@pref{@arg{@index@_file}@}{tags}@"></td>
       </tr>
       <tr class=even>
+       <td class="prefs_name">@label:prefs.weight@</td>
+       <td class="prefs_value"><input size=40 type=text name="@index@_weight" value="@pref{@arg{@index@_file}@}{weight}@"></td>
+      </tr>
+      <tr class=odd>
        <td class="prefs_name">@label:prefs.random@</td>
        <td class="prefs_value"><input type=checkbox
         name="@index@_random" value=true
index fff18664a77c951855f3827045145cdad9132fa7..c37872d6baae568e5ff5e955f4c0a071d10f0cb4 100644 (file)
@@ -173,6 +173,7 @@ def default_config(encoding="UTF-8"):
 collection fs %s %s/tracks
 scratch %s/scratch.ogg
 gap 0
+queue_pad 5
 stopword 01 02 03 04 05 06 07 08 09 10
 stopword 1 2 3 4 5 6 7 8 9
 stopword 11 12 13 14 15 16 17 18 19 20
index 7ed10a076aa632419475a745487fec84059a0aaf..dae37b47d473206d825940eb4e7c55796a6e5dbe 100755 (executable)
 #
 import dtest,time,disorder,re
 
+class wait_monitor(disorder.monitor):
+    def queue(self, q):
+        return False
+
 def test():
     """Check the queue is padded to the (default) configured length"""
     dtest.start_daemon()
     dtest.create_user()
-    print " waiting for queue to be populated..."
-    class wait_monitor(disorder.monitor):
-        def queue(self, q):
-            return False
-    wait_monitor().run()
     c = disorder.client()
-    print " getting queue via python module"
+    print " disabling play"
+    c.disable()
+    print " waiting for queue to be populated..."
     q = c.queue()
-    assert len(q) == 10, "queue is at proper length"
+    while len(q) < 5:
+        print "  queue at %d tracks" % len(q)
+        wait_monitor().run()
+        q = c.queue()
     print " getting queue via disorder(1)"
     q = dtest.command(["disorder",
                        "--config", disorder._configfile, "--no-per-user-config",
                        "queue"])
     tracks = filter(lambda s: re.match("^track", s), q)
-    assert len(tracks) == 10, "queue is at proper length"
+    assert len(tracks) == 5, "queue is at proper length"
     print " disabling random play"
     c.random_disable()
     print " emptying queue"
@@ -49,9 +53,12 @@ def test():
     assert q == [], "checking queue is empty"
     print " enabling random play"
     c.random_enable()
-    print " checking queue refills"
+    print " waiting for queue to refill..."
     q = c.queue()
-    assert len(q) == 10, "queue is at proper length"
+    while len(q) < 5:
+        print "  queue at %d tracks" % len(q)
+        wait_monitor().run()
+        q = c.queue()
     print " disabling all play"
     c.random_disable()
     c.disable()
index 2e5f86e85de8f3060faab9098e89e39d701abb22..1f3a39fe568775f767ed7b2ee74d512fd7cc6c4c 100755 (executable)
@@ -79,6 +79,22 @@ def test():
     print " checking confirmed user can log in"
     jc = disorder.client(user="joe", password="joepass")
     jc.version()
+    print " checking new user's email address"
+    assert c.userinfo("joe", "email") == "joe@nowhere.invalid"
+    print " checking can change user email address"
+    c.edituser("joe", "email", "joe@another.invalid")
+    assert c.userinfo("joe", "email") == "joe@another.invalid"
+    print " checking bad email address rejected"
+    try:
+      c.edituser("joe", "email", "invalid")
+      print "*** should not be able to set invalid email address ***"
+      assert False
+    except disorder.operationError:
+      pass                              # good
+    assert c.userinfo("joe", "email") == "joe@another.invalid"
+    print " checking removal of email address"
+    c.edituser("joe", "email", "")
+    assert c.userinfo("joe", "email") == None
 
 if __name__ == '__main__':
     dtest.run()