From: Richard Kettlewell Date: Sun, 18 Oct 2009 21:54:18 +0000 (+0100) Subject: Merge from disorder.dev. X-Git-Tag: 5.0~86^2 X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~mdw/git/disorder/commitdiff_plain/4942ee7d61bf22ba38bf026c7d05028cb7db0d54?hp=4265e5d362914f3732b4035dcf67162e525e0142 Merge from disorder.dev. --- diff --git a/clients/disorder.c b/clients/disorder.c index ccfaf20..a246eee 100644 --- a/clients/disorder.c +++ b/clients/disorder.c @@ -33,6 +33,7 @@ #include #include #include +#include #include "configuration.h" #include "syscalls.h" @@ -52,6 +53,7 @@ #include "version.h" #include "dateparse.h" #include "trackdb.h" +#include "inputline.h" static disorder_client *client; @@ -188,15 +190,34 @@ static void cf_queue(char attribute((unused)) **argv) { } static void cf_quack(char attribute((unused)) **argv) { - xprintf("\n" - " .------------------.\n" - " | Naath is a babe! |\n" - " `---------+--------'\n" - " \\\n" - " >0\n" - " (<)'\n" - "~~~~~~~~~~~~~~~~~~~~~~\n" - "\n"); + if(!strcasecmp(nl_langinfo(CODESET), "utf-8")) { +#define TL "\xE2\x95\xAD" +#define TR "\xE2\x95\xAE" +#define BR "\xE2\x95\xAF" +#define BL "\xE2\x95\xB0" +#define H "\xE2\x94\x80" +#define V "\xE2\x94\x82" +#define T "\xE2\x94\xAC" + xprintf("\n" + " "TL H H H H H H H H H H H H H H H H H H TR"\n" + " "V" Naath is a babe! "V"\n" + " "BL H H H H H H H H H T H H H H H H H H BR"\n" + " \\\n" + " >0\n" + " (<)'\n" + "~~~~~~~~~~~~~~~~~~~~~~\n" + "\n"); + } else { + xprintf("\n" + " .------------------.\n" + " | Naath is a babe! |\n" + " `---------+--------'\n" + " \\\n" + " >0\n" + " (<)'\n" + "~~~~~~~~~~~~~~~~~~~~~~\n" + "\n"); + } } static void cf_somelist(char **argv, @@ -585,6 +606,61 @@ static void cf_adopt(char **argv) { exit(EXIT_FAILURE); } +static void cf_playlists(char attribute((unused)) **argv) { + char **vec; + + if(disorder_playlists(getclient(), &vec, 0)) + exit(EXIT_FAILURE); + while(*vec) + xprintf("%s\n", nullcheck(utf82mb(*vec++))); +} + +static void cf_playlist_del(char **argv) { + if(disorder_playlist_delete(getclient(), argv[0])) + exit(EXIT_FAILURE); +} + +static void cf_playlist_get(char **argv) { + char **vec; + + if(disorder_playlist_get(getclient(), argv[0], &vec, 0)) + exit(EXIT_FAILURE); + while(*vec) + xprintf("%s\n", nullcheck(utf82mb(*vec++))); +} + +static void cf_playlist_set(char **argv) { + struct vector v[1]; + FILE *input; + const char *tag; + char *l; + + if(argv[1]) { + // Read track list from file + if(!(input = fopen(argv[1], "r"))) + fatal(errno, "opening %s", argv[1]); + tag = argv[1]; + } else { + // Read track list from standard input + input = stdin; + tag = "stdin"; + } + vector_init(v); + while(!inputline(tag, input, &l, '\n')) { + if(!strcmp(l, ".")) + break; + vector_append(v, l); + } + if(ferror(input)) + fatal(errno, "reading %s", tag); + if(input != stdin) + fclose(input); + if(disorder_playlist_lock(getclient(), argv[0]) + || disorder_playlist_set(getclient(), argv[0], v->vec, v->nvec) + || disorder_playlist_unlock(getclient())) + exit(EXIT_FAILURE); +} + static const struct command { const char *name; int min, max; @@ -638,6 +714,14 @@ static const struct command { "Add TRACKS to the end of the queue" }, { "playing", 0, 0, cf_playing, 0, "", "Report the playing track" }, + { "playlist-del", 1, 1, cf_playlist_del, 0, "PLAYLIST", + "Delete a playlist" }, + { "playlist-get", 1, 1, cf_playlist_get, 0, "PLAYLIST", + "Get the contents of a playlist" }, + { "playlist-set", 1, 2, cf_playlist_set, isarg_filename, "PLAYLIST [PATH]", + "Set the contents of a playlist" }, + { "playlists", 0, 0, cf_playlists, 0, "", + "List playlists" }, { "prefs", 1, 1, cf_prefs, 0, "TRACK", "Display all the preferences for TRACK" }, { "quack", 0, 0, cf_quack, 0, 0, 0 }, diff --git a/disobedience/Makefile.am b/disobedience/Makefile.am index c7b702b..f8bdb14 100644 --- a/disobedience/Makefile.am +++ b/disobedience/Makefile.am @@ -28,7 +28,7 @@ disobedience_SOURCES=disobedience.h disobedience.c client.c queue.c \ choose.c choose-menu.c choose-search.c popup.c misc.c \ control.c properties.c menu.c log.c progress.c login.c rtp.c \ help.c ../lib/memgc.c settings.c users.c lookup.c choose.h \ - popup.h + popup.h playlists.c disobedience_LDADD=../lib/libdisorder.a $(LIBPCRE) $(LIBGC) $(LIBGCRYPT) \ $(LIBASOUND) $(COREAUDIO) $(LIBDB) $(LIBICONV) disobedience_LDFLAGS=$(GTK_LIBS) diff --git a/disobedience/disobedience.c b/disobedience/disobedience.c index 38fc6eb..7fad2eb 100644 --- a/disobedience/disobedience.c +++ b/disobedience/disobedience.c @@ -243,6 +243,7 @@ static gboolean periodic_slow(gpointer attribute((unused)) data) { /* Update everything to be sure that the connection to the server hasn't * mysteriously gone stale on us. */ all_update(); + event_raise("periodic-slow", 0); /* Recheck RTP status too */ check_rtp_address(0, 0, 0); return TRUE; /* don't remove me */ @@ -285,6 +286,7 @@ static gboolean periodic_fast(gpointer attribute((unused)) data) { recheck_rights = 0; if(recheck_rights) check_rights(); + event_raise("periodic-fast", 0); return TRUE; } @@ -493,6 +495,9 @@ int main(int argc, char **argv) { disorder_eclient_version(client, version_completed, 0); event_register("log-connected", check_rtp_address, 0); suppress_actions = 0; +#if PLAYLISTS + playlists_init(); +#endif /* If no password is set yet pop up a login box */ if(!config->password) login_box(); diff --git a/disobedience/disobedience.h b/disobedience/disobedience.h index ca5f7ef..f4678c7 100644 --- a/disobedience/disobedience.h +++ b/disobedience/disobedience.h @@ -252,6 +252,20 @@ void load_settings(void); void set_tool_colors(GtkWidget *w); void popup_settings(void); +/* Playlists */ + +#if PLAYLISTS +void playlists_init(void); +void edit_playlists(gpointer callback_data, + guint callback_action, + GtkWidget *menu_item); +extern char **playlists; +extern int nplaylists; +extern GtkWidget *playlists_widget; +extern GtkWidget *playlists_menu; +extern GtkWidget *editplaylists_widget; +#endif + #endif /* DISOBEDIENCE_H */ /* diff --git a/disobedience/log.c b/disobedience/log.c index 652c4e9..f1c4f79 100644 --- a/disobedience/log.c +++ b/disobedience/log.c @@ -42,6 +42,12 @@ static void log_volume(void *v, int l, int r); static void log_rescanned(void *v); static void log_rights_changed(void *v, rights_type r); static void log_adopted(void *v, const char *id, const char *user); +static void log_playlist_created(void *v, + const char *playlist, const char *sharing); +static void log_playlist_modified(void *v, + const char *playlist, const char *sharing); +static void log_playlist_deleted(void *v, + const char *playlist); /** @brief Callbacks for server state monitoring */ const disorder_eclient_log_callbacks log_callbacks = { @@ -59,7 +65,10 @@ const disorder_eclient_log_callbacks log_callbacks = { .volume = log_volume, .rescanned = log_rescanned, .rights_changed = log_rights_changed, - .adopted = log_adopted + .adopted = log_adopted, + .playlist_created = log_playlist_created, + .playlist_modified = log_playlist_modified, + .playlist_deleted = log_playlist_deleted, }; /** @brief Update everything */ @@ -211,6 +220,23 @@ static void log_adopted(void attribute((unused)) *v, event_raise("queue-changed", 0); } +static void log_playlist_created(void attribute((unused)) *v, + const char *playlist, + const char attribute((unused)) *sharing) { + event_raise("playlist-created", (void *)playlist); +} + +static void log_playlist_modified(void attribute((unused)) *v, + const char *playlist, + const char attribute((unused)) *sharing) { + event_raise("playlist-modified", (void *)playlist); +} + +static void log_playlist_deleted(void attribute((unused)) *v, + const char *playlist) { + event_raise("playlist-deleted", (void *)playlist); +} + /* Local Variables: c-basic-offset:2 diff --git a/disobedience/menu.c b/disobedience/menu.c index 15fb4fb..0243139 100644 --- a/disobedience/menu.c +++ b/disobedience/menu.c @@ -24,6 +24,11 @@ static GtkWidget *selectall_widget; static GtkWidget *selectnone_widget; static GtkWidget *properties_widget; +#if PLAYLISTS +GtkWidget *playlists_widget; +GtkWidget *playlists_menu; +GtkWidget *editplaylists_widget; +#endif /** @brief Main menu widgets */ GtkItemFactory *mainmenufactory; @@ -113,7 +118,7 @@ static void edit_menu_show(GtkWidget attribute((unused)) *widget, && t->selectnone_sensitive(t->extra)); } } - + /** @brief Fetch version in order to display the about... popup */ static void about_popup(gpointer attribute((unused)) callback_data, guint attribute((unused)) callback_action, @@ -293,6 +298,17 @@ GtkWidget *menubar(GtkWidget *w) { 0, /* item_type */ 0 /* extra_data */ }, +#if PLAYLISTS + { + (char *)"/Edit/Edit playlists", /* path */ + 0, /* accelerator */ + edit_playlists, /* callback */ + 0, /* callback_action */ + 0, /* item_type */ + 0 /* extra_data */ + }, +#endif + { (char *)"/Control", /* path */ @@ -334,6 +350,16 @@ GtkWidget *menubar(GtkWidget *w) { (char *)"", /* item_type */ 0 /* extra_data */ }, +#if PLAYLISTS + { + (char *)"/Control/Activate playlist", /* path */ + 0, /* accelerator */ + 0, /* callback */ + 0, /* callback_action */ + (char *)"", /* item_type */ + 0 /* extra_data */ + }, +#endif { (char *)"/Help", /* path */ @@ -378,15 +404,27 @@ GtkWidget *menubar(GtkWidget *w) { "/Edit/Deselect all tracks"); properties_widget = gtk_item_factory_get_widget(mainmenufactory, "/Edit/Track properties"); +#if PLAYLISTS + playlists_widget = gtk_item_factory_get_item(mainmenufactory, + "/Control/Activate playlist"); + playlists_menu = gtk_item_factory_get_widget(mainmenufactory, + "/Control/Activate playlist"); + editplaylists_widget = gtk_item_factory_get_widget(mainmenufactory, + "/Edit/Edit playlists"); +#endif assert(selectall_widget != 0); assert(selectnone_widget != 0); assert(properties_widget != 0); +#if PLAYLISTS + assert(playlists_widget != 0); + assert(playlists_menu != 0); + assert(editplaylists_widget != 0); +#endif - GtkWidget *edit_widget = gtk_item_factory_get_widget(mainmenufactory, "/Edit"); g_signal_connect(edit_widget, "show", G_CALLBACK(edit_menu_show), 0); - + event_register("rights-changed", menu_rights_changed, 0); users_set_sensitive(0); m = gtk_item_factory_get_widget(mainmenufactory, diff --git a/disobedience/playlists.c b/disobedience/playlists.c new file mode 100644 index 0000000..cd8979d --- /dev/null +++ b/disobedience/playlists.c @@ -0,0 +1,338 @@ +/* + * 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/playlists.c + * @brief Playlist for Disobedience + * + * The playlists management window contains: + * - a list of all playlists + * - an add button + * - a delete button + * - a drag+drop capable view of the playlist + * - a close button + */ +#include "disobedience.h" + +#if PLAYLISTS + +static void playlists_updated(void *v, + const char *err, + int nvec, char **vec); + +/** @brief Playlist editing window */ +static GtkWidget *playlists_window; + +/** @brief Tree model for list of playlists */ +static GtkListStore *playlists_list; + +/** @brief Selection for list of playlists */ +static GtkTreeSelection *playlists_selection; + +/** @brief Currently selected playlist */ +static const char *playlists_selected; + +/** @brief Delete button */ +static GtkWidget *playlists_delete_button; + +/** @brief Current list of playlists or NULL */ +char **playlists; + +/** @brief Count of playlists */ +int nplaylists; + +/** @brief Schedule an update to the list of playlists */ +static void playlists_update(const char attribute((unused)) *event, + void attribute((unused)) *eventdata, + void attribute((unused)) *callbackdata) { + disorder_eclient_playlists(client, playlists_updated, 0); +} + +/** @brief qsort() callback for playlist name comparison */ +static int playlistcmp(const void *ap, const void *bp) { + const char *a = *(char **)ap, *b = *(char **)bp; + const char *ad = strchr(a, '.'), *bd = strchr(b, '.'); + int c; + + /* Group owned playlists by owner */ + if(ad && bd) { + const int adn = ad - a, bdn = bd - b; + if((c = strncmp(a, b, adn < bdn ? adn : bdn))) + return c; + /* Lexical order within playlists of a single owner */ + return strcmp(ad + 1, bd + 1); + } + + /* Owned playlists after shared ones */ + if(ad) { + return 1; + } else if(bd) { + return -1; + } + + /* Lexical order of shared playlists */ + return strcmp(a, b); +} + +/** @brief Called with a new list of playlists */ +static void playlists_updated(void attribute((unused)) *v, + const char *err, + int nvec, char **vec) { + if(err) { + playlists = 0; + nplaylists = -1; + /* Probably means server does not support playlists */ + } else { + playlists = vec; + nplaylists = nvec; + qsort(playlists, nplaylists, sizeof (char *), playlistcmp); + } + /* Tell our consumers */ + event_raise("playlists-updated", 0); +} + +/** @brief Called to activate a playlist */ +static void menu_activate_playlist(GtkMenuItem *menuitem, + gpointer attribute((unused)) user_data) { + GtkLabel *label = GTK_LABEL(GTK_BIN(menuitem)->child); + const char *playlist = gtk_label_get_text(label); + + fprintf(stderr, "activate playlist %s\n", playlist); /* TODO */ +} + +/** @brief Called when the playlists change */ +static void menu_playlists_changed(const char attribute((unused)) *event, + void attribute((unused)) *eventdata, + void attribute((unused)) *callbackdata) { + if(!playlists_menu) + return; /* OMG too soon */ + GtkMenuShell *menu = GTK_MENU_SHELL(playlists_menu); + /* TODO: we could be more sophisticated and only insert/remove widgets as + * needed. For now that's too much effort. */ + while(menu->children) + gtk_container_remove(GTK_CONTAINER(menu), GTK_WIDGET(menu->children->data)); + /* NB nplaylists can be -1 as well as 0 */ + for(int n = 0; n < nplaylists; ++n) { + GtkWidget *w = gtk_menu_item_new_with_label(playlists[n]); + g_signal_connect(w, "activate", G_CALLBACK(menu_activate_playlist), 0); + gtk_widget_show(w); + gtk_menu_shell_append(menu, w); + } + gtk_widget_set_sensitive(playlists_widget, + nplaylists > 0); + gtk_widget_set_sensitive(editplaylists_widget, + nplaylists >= 0); +} + +/** @brief (Re-)populate the playlist tree model */ +static void playlists_fill(void) { + GtkTreeIter iter[1]; + + if(!playlists_list) + playlists_list = gtk_list_store_new(1, G_TYPE_STRING); + gtk_list_store_clear(playlists_list); + for(int n = 0; n < nplaylists; ++n) + gtk_list_store_insert_with_values(playlists_list, iter, n/*position*/, + 0, playlists[n], /* column 0 */ + -1); /* no more cols */ + // TODO reselect whatever was formerly selected if possible, if not then + // zap the contents view +} + +/** @brief Called when the selection might have changed */ +static void playlists_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(playlists_selection, 0, &iter)) { + gtk_tree_model_get(GTK_TREE_MODEL(playlists_list), &iter, + 0, &gselected, -1); + selected = xstrdup(gselected); + g_free(gselected); + } else + selected = 0; + /* Eliminate no-change cases */ + if(!selected && !playlists_selected) + return; + if(selected && playlists_selected && !strcmp(selected, playlists_selected)) + return; + /* There's been a change */ + playlists_selected = selected; + if(playlists_selected) { + fprintf(stderr, "playlists selection changed\n'"); /* TODO */ + gtk_widget_set_sensitive(playlists_delete_button, 1); + } else + gtk_widget_set_sensitive(playlists_delete_button, 0); +} + +/** @brief Called when the 'add' button is pressed */ +static void playlists_add(GtkButton attribute((unused)) *button, + gpointer attribute((unused)) userdata) { + /* Unselect whatever is selected */ + gtk_tree_selection_unselect_all(playlists_selection); + fprintf(stderr, "playlists_add\n");/* TODO */ +} + +/** @brief Called when the 'Delete' button is pressed */ +static void playlists_delete(GtkButton attribute((unused)) *button, + gpointer attribute((unused)) userdata) { + GtkWidget *yesno; + int res; + + if(!playlists_selected) + return; /* shouldn't happen */ + yesno = gtk_message_dialog_new(GTK_WINDOW(playlists_window), + GTK_DIALOG_MODAL, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_YES_NO, + "Do you really want to delete user %s?" + " This action cannot be undone.", + playlists_selected); + res = gtk_dialog_run(GTK_DIALOG(yesno)); + gtk_widget_destroy(yesno); + if(res == GTK_RESPONSE_YES) { + disorder_eclient_playlist_delete(client, + NULL/*playlists_delete_completed*/, + playlists_selected, + NULL); + } +} + +/** @brief Table of buttons below the playlist list */ +static struct button playlists_buttons[] = { + { + GTK_STOCK_ADD, + playlists_add, + "Create a new playlist", + 0 + }, + { + GTK_STOCK_REMOVE, + playlists_delete, + "Delete a playlist", + 0 + }, +}; +#define NPLAYLISTS_BUTTONS (sizeof playlists_buttons / sizeof *playlists_buttons) + +/** @brief Keypress handler */ +static gboolean playlists_keypress(GtkWidget attribute((unused)) *widget, + GdkEventKey *event, + gpointer attribute((unused)) user_data) { + if(event->state) + return FALSE; + switch(event->keyval) { + case GDK_Escape: + gtk_widget_destroy(playlists_window); + return TRUE; + default: + return FALSE; + } +} + +void edit_playlists(gpointer attribute((unused)) callback_data, + guint attribute((unused)) callback_action, + GtkWidget attribute((unused)) *menu_item) { + GtkWidget *tree, *hbox, *vbox, *buttons; + GtkCellRenderer *cr; + GtkTreeViewColumn *col; + + /* If the window already exists, raise it */ + if(playlists_window) { + gtk_window_present(GTK_WINDOW(playlists_window)); + return; + } + /* Create the window */ + playlists_window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_widget_set_style(playlists_window, tool_style); + g_signal_connect(playlists_window, "destroy", + G_CALLBACK(gtk_widget_destroyed), &playlists_window); + gtk_window_set_title(GTK_WINDOW(playlists_window), "Playlists Management"); + /* TODO loads of this is very similar to (copied from!) users.c - can we + * de-dupe? */ + /* Keyboard shortcuts */ + g_signal_connect(playlists_window, "key-press-event", + G_CALLBACK(playlists_keypress), 0); + /* default size is too small */ + gtk_window_set_default_size(GTK_WINDOW(playlists_window), 240, 240); + /* Create the list of playlist and populate it */ + playlists_fill(); + /* Create the tree view */ + tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(playlists_list)); + /* ...and the renderers for it */ + cr = gtk_cell_renderer_text_new(); + col = gtk_tree_view_column_new_with_attributes("Playlist", + 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 */ + playlists_selected = NULL; + playlists_selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree)); + gtk_tree_selection_set_mode(playlists_selection, GTK_SELECTION_BROWSE); + g_signal_connect(playlists_selection, "changed", + G_CALLBACK(playlists_selection_changed), NULL); + + /* Create the control buttons */ + buttons = create_buttons_box(playlists_buttons, + NPLAYLISTS_BUTTONS, + gtk_hbox_new(FALSE, 1)); + playlists_delete_button = playlists_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); + + 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); + // TODO something to edit the playlist in + //gtk_box_pack_start(GTK_BOX(hbox), vbox2, TRUE/*expand*/, TRUE/*fill*/, 0); + gtk_container_add(GTK_CONTAINER(playlists_window), frame_widget(hbox, NULL)); + gtk_widget_show_all(playlists_window); +} + +/** @brief Initialize playlist support */ +void playlists_init(void) { + /* We re-get all playlists upon any change... */ + event_register("playlist-created", playlists_update, 0); + event_register("playlist-modified", playlists_update, 0); + event_register("playlist-deleted", playlists_update, 0); + /* ...and on reconnection */ + event_register("log-connected", playlists_update, 0); + /* ...and from time to time */ + event_register("periodic-slow", playlists_update, 0); + /* ...and at startup */ + event_register("playlists-updated", menu_playlists_changed, 0); + playlists_update(0, 0, 0); +} + +#endif + +/* +Local Variables: +c-basic-offset:2 +comment-column:40 +fill-column:79 +indent-tabs-mode:nil +End: +*/ diff --git a/disobedience/queue-generic.c b/disobedience/queue-generic.c index aa29c28..f14f424 100644 --- a/disobedience/queue-generic.c +++ b/disobedience/queue-generic.c @@ -143,7 +143,7 @@ const char *column_length(const struct queue_entry *q, /** @brief Return the @ref queue_entry corresponding to @p iter * @param model Model that owns @p iter * @param iter Tree iterator - * @return ID string + * @return Pointer to queue entry */ struct queue_entry *ql_iter_to_q(GtkTreeModel *model, GtkTreeIter *iter) { @@ -402,6 +402,120 @@ void ql_new_queue(struct queuelike *ql, --suppress_actions; } +/* Drag and drop has to be figured out experimentally, because it is not well + * documented. + * + * First you get a row-inserted. The path argument points to the destination + * row but this will not yet have had its values set. The source row is still + * present. AFAICT the iter argument points to the same place. + * + * Then you get a row-deleted. The path argument identifies the row that was + * deleted. By this stage the row inserted above has acquired its values. + * + * A complication is that the deletion will move the inserted row. For + * instance, if you do a drag that moves row 1 down to after the track that was + * formerly on row 9, in the row-inserted call it will show up as row 10, but + * in the row-deleted call, row 1 will have been deleted thus making the + * inserted row be row 9. + * + * So when we see the row-inserted we have no idea what track to move. + * Therefore we stash it until we see a row-deleted. + */ + +/** @brief row-inserted callback */ +static void ql_row_inserted(GtkTreeModel attribute((unused)) *treemodel, + GtkTreePath *path, + GtkTreeIter attribute((unused)) *iter, + gpointer user_data) { + struct queuelike *const ql = user_data; + if(!suppress_actions) { +#if 0 + char *ps = gtk_tree_path_to_string(path); + GtkTreeIter piter[1]; + gboolean pi = gtk_tree_model_get_iter(treemodel, piter, path); + struct queue_entry *pq = pi ? ql_iter_to_q(treemodel, piter) : 0; + struct queue_entry *iq = ql_iter_to_q(treemodel, iter); + + fprintf(stderr, "row-inserted %s path=%s pi=%d pq=%p path=%s iq=%p iter=%s\n", + ql->name, + ps, + pi, + pq, + (pi + ? (pq ? pq->track : "(pq=0)") + : "(pi=FALSE)"), + iq, + iq ? iq->track : "(iq=0)"); + + GtkTreeIter j[1]; + gboolean jt = gtk_tree_model_get_iter_first(treemodel, j); + int row = 0; + while(jt) { + struct queue_entry *q = ql_iter_to_q(treemodel, j); + fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)"); + jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql->store), j); + } + g_free(ps); +#endif + /* Remember an iterator pointing at the insertion target */ + if(ql->drag_target) + gtk_tree_path_free(ql->drag_target); + ql->drag_target = gtk_tree_path_copy(path); + } +} + +/** @brief row-deleted callback */ +static void ql_row_deleted(GtkTreeModel attribute((unused)) *treemodel, + GtkTreePath *path, + gpointer user_data) { + struct queuelike *const ql = user_data; + + if(!suppress_actions) { +#if 0 + char *ps = gtk_tree_path_to_string(path); + fprintf(stderr, "row-deleted %s path=%s ql->drag_target=%s\n", + ql->name, ps, gtk_tree_path_to_string(ql->drag_target)); + GtkTreeIter j[1]; + gboolean jt = gtk_tree_model_get_iter_first(treemodel, j); + int row = 0; + while(jt) { + struct queue_entry *q = ql_iter_to_q(treemodel, j); + fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)"); + jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql->store), j); + } + g_free(ps); +#endif + if(!ql->drag_target) { + error(0, "%s: unsuppressed row-deleted with no row-inserted", + ql->name); + return; + } + + /* Get the source and destination row numbers. */ + int srcrow = gtk_tree_path_get_indices(path)[0]; + int dstrow = gtk_tree_path_get_indices(ql->drag_target)[0]; + //fprintf(stderr, "srcrow=%d dstrow=%d\n", srcrow, dstrow); + + /* Note that the source row is computed AFTER the destination has been + * inserted, since GTK+ does the insert before the delete. Therefore if + * the source row is south (higher row number) of the destination, it will + * be one higher than expected. + * + * For instance if we drag row 1 to before row 0 we will see row-inserted + * for row 0 but then a row-deleted for row 2. + */ + if(srcrow > dstrow) + --srcrow; + + /* Tell the queue implementation */ + ql->drop(srcrow, dstrow); + + /* Dispose of stashed data */ + gtk_tree_path_free(ql->drag_target); + ql->drag_target = 0; + } +} + /** @brief Initialize a @ref queuelike */ GtkWidget *init_queuelike(struct queuelike *ql) { D(("init_queuelike")); @@ -447,6 +561,17 @@ GtkWidget *init_queuelike(struct queuelike *ql) { g_signal_connect(ql->view, "button-press-event", G_CALLBACK(ql_button_release), ql); + /* Drag+drop*/ + if(ql->drop) { + gtk_tree_view_set_reorderable(GTK_TREE_VIEW(ql->view), TRUE); + g_signal_connect(ql->store, + "row-inserted", + G_CALLBACK(ql_row_inserted), ql); + g_signal_connect(ql->store, + "row-deleted", + G_CALLBACK(ql_row_deleted), ql); + } + /* TODO style? */ ql->init(); diff --git a/disobedience/queue-generic.h b/disobedience/queue-generic.h index 4b31fe9..8dd9fdb 100644 --- a/disobedience/queue-generic.h +++ b/disobedience/queue-generic.h @@ -90,6 +90,18 @@ struct queuelike { /** @brief Menu callbacks */ struct tabtype tabtype; + + /** @brief Drag-drop callback, or NULL for no drag+drop + * @param src Row to move + * @param dst Destination position + * + * If the rearrangement is impossible then the displayed queue must be put + * back. + */ + void (*drop)(int src, int dst); + + /** @brief Stashed drag target row */ + GtkTreePath *drag_target; }; enum { diff --git a/disobedience/queue.c b/disobedience/queue.c index 7779c0f..a051090 100644 --- a/disobedience/queue.c +++ b/disobedience/queue.c @@ -152,6 +152,61 @@ static void queue_init(void) { g_timeout_add(1000/*ms*/, playing_periodic, 0); } +static void queue_move_completed(void attribute((unused)) *v, + const char *err) { + if(err) { + popup_protocol_error(0, err); + return; + } + /* The log should tell us the queue changed so we do no more here */ +} + +/** @brief Called when drag+drop completes */ +static void queue_drop(int src, int dst) { + struct queue_entry *sq, *dq; + int n; + + //fprintf(stderr, "queue_drop %d -> %d\n", src, dst); + if(playing_track) { + /* If there's a playing track then you can't drag it anywhere */ + if(src == 0) { + //fprintf(stderr, "cannot drag playing track\n"); + queue_playing_changed(); + return; + } + /* If you try to drop before the playing track we assume you missed and + * mean after instead */ + if(!dst) + dst = 1; + //fprintf(stderr, "...adjusted to %d -> %d\n\n", src, dst); + } + /* Find the entry to move */ + for(n = 0, sq = ql_queue.q; n < src; ++n) + sq = sq->next; + /*fprintf(stderr, "source=%s (%s)\n", + sq->id, sq->track);*/ + const int after = dst - 1; + if(after == -1) + dq = 0; + else + /* Find the entry to insert after */ + for(n = 0, dq = ql_queue.q; n < after; ++n) + dq = dq->next; + if(dq == playing_track) + dq = 0; +#if 0 + if(dq) + fprintf(stderr, "after=%s (%s)\n", + dq->id, dq->track); + else + fprintf(stderr, "after=NULL\n"); +#endif + disorder_eclient_moveafter(client, + dq ? dq->id : "", + 1, &sq->id, + queue_move_completed, NULL); +} + /** @brief Columns for the queue */ static const struct queue_column queue_columns[] = { { "When", column_when, 0, COL_RIGHT }, @@ -178,161 +233,10 @@ struct queuelike ql_queue = { .columns = queue_columns, .ncolumns = sizeof queue_columns / sizeof *queue_columns, .menuitems = queue_menuitems, - .nmenuitems = sizeof queue_menuitems / sizeof *queue_menuitems + .nmenuitems = sizeof queue_menuitems / sizeof *queue_menuitems, + .drop = queue_drop }; -/* Drag and drop has to be figured out experimentally, because it is not well - * documented. - * - * First you get a row-inserted. The path argument points to the destination - * row but this will not yet have had its values set. The source row is still - * present. AFAICT the iter argument points to the same place. - * - * Then you get a row-deleted. The path argument identifies the row that was - * deleted. By this stage the row inserted above has acquired its values. - * - * A complication is that the deletion will move the inserted row. For - * instance, if you do a drag that moves row 1 down to after the track that was - * formerly on row 9, in the row-inserted call it will show up as row 10, but - * in the row-deleted call, row 1 will have been deleted thus making the - * inserted row be row 9. - * - * So when we see the row-inserted we have no idea what track to move. - * Therefore we stash it until we see a row-deleted. - */ - -/** @brief Target row for drag */ -static int queue_drag_target = -1; - -static void queue_move_completed(void attribute((unused)) *v, - const char *err) { - if(err) { - popup_protocol_error(0, err); - return; - } - /* The log should tell us the queue changed so we do no more here */ -} - -static void queue_row_deleted(GtkTreeModel *treemodel, - GtkTreePath *path, - gpointer attribute((unused)) user_data) { - if(!suppress_actions) { -#if 0 - char *ps = gtk_tree_path_to_string(path); - fprintf(stderr, "row-deleted path=%s queue_drag_target=%d\n", - ps, queue_drag_target); - GtkTreeIter j[1]; - gboolean jt = gtk_tree_model_get_iter_first(treemodel, j); - int row = 0; - while(jt) { - struct queue_entry *q = ql_iter_to_q(treemodel, j); - fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)"); - jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql_queue.store), j); - } - g_free(ps); -#endif - if(queue_drag_target < 0) { - error(0, "unsuppressed row-deleted with no row-inserted"); - return; - } - int drag_source = gtk_tree_path_get_indices(path)[0]; - - /* If the drag is downwards (=towards higher row numbers) then the target - * will have been moved upwards (=towards lower row numbers) by one row. */ - if(drag_source < queue_drag_target) - --queue_drag_target; - - /* Find the track to move */ - GtkTreeIter src[1]; - gboolean srcv = gtk_tree_model_iter_nth_child(treemodel, src, NULL, - queue_drag_target); - if(!srcv) { - error(0, "cannot get iterator to drag target %d", queue_drag_target); - queue_playing_changed(); - queue_drag_target = -1; - return; - } - struct queue_entry *srcq = ql_iter_to_q(treemodel, src); - assert(srcq); - //fprintf(stderr, "move %s %s\n", srcq->id, srcq->track); - - /* Don't allow the currently playing track to be moved. As above, we put - * the queue back into the right order straight away. */ - if(srcq == playing_track) { - //fprintf(stderr, "cannot move currently playing track\n"); - queue_playing_changed(); - queue_drag_target = -1; - return; - } - - /* Find the destination */ - struct queue_entry *dstq; - if(queue_drag_target) { - GtkTreeIter dst[1]; - gboolean dstv = gtk_tree_model_iter_nth_child(treemodel, dst, NULL, - queue_drag_target - 1); - if(!dstv) { - error(0, "cannot get iterator to drag target predecessor %d", - queue_drag_target - 1); - queue_playing_changed(); - queue_drag_target = -1; - return; - } - dstq = ql_iter_to_q(treemodel, dst); - assert(dstq); - if(dstq == playing_track) - dstq = 0; - } else - dstq = 0; - /* NB if the user attempts to move a queued track before the currently - * playing track we assume they just missed a bit, and put it after. */ - //fprintf(stderr, " target %s %s\n", dstq ? dstq->id : "(none)", dstq ? dstq->track : "(none)"); - /* Now we know what is to be moved. We need to know the preceding queue - * entry so we can move it. */ - disorder_eclient_moveafter(client, - dstq ? dstq->id : "", - 1, &srcq->id, - queue_move_completed, NULL); - queue_drag_target = -1; - } -} - -static void queue_row_inserted(GtkTreeModel attribute((unused)) *treemodel, - GtkTreePath *path, - GtkTreeIter attribute((unused)) *iter, - gpointer attribute((unused)) user_data) { - if(!suppress_actions) { -#if 0 - char *ps = gtk_tree_path_to_string(path); - GtkTreeIter piter[1]; - gboolean pi = gtk_tree_model_get_iter(treemodel, piter, path); - struct queue_entry *pq = pi ? ql_iter_to_q(treemodel, piter) : 0; - struct queue_entry *iq = ql_iter_to_q(treemodel, iter); - - fprintf(stderr, "row-inserted path=%s pi=%d pq=%p path=%s iq=%p iter=%s\n", - ps, - pi, - pq, - (pi - ? (pq ? pq->track : "(pq=0)") - : "(pi=FALSE)"), - iq, - iq ? iq->track : "(iq=0)"); - - GtkTreeIter j[1]; - gboolean jt = gtk_tree_model_get_iter_first(treemodel, j); - int row = 0; - while(jt) { - struct queue_entry *q = ql_iter_to_q(treemodel, j); - fprintf(stderr, " %2d %s\n", row++, q ? q->track : "(no q)"); - jt = gtk_tree_model_iter_next(GTK_TREE_MODEL(ql_queue.store), j); - } - g_free(ps); -#endif - queue_drag_target = gtk_tree_path_get_indices(path)[0]; - } -} - /** @brief Called when a key is pressed in the queue tree view */ static gboolean queue_key_press(GtkWidget attribute((unused)) *widget, GdkEventKey *event, @@ -353,14 +257,6 @@ static gboolean queue_key_press(GtkWidget attribute((unused)) *widget, GtkWidget *queue_widget(void) { GtkWidget *const w = init_queuelike(&ql_queue); - /* Enable drag+drop */ - gtk_tree_view_set_reorderable(GTK_TREE_VIEW(ql_queue.view), TRUE); - g_signal_connect(ql_queue.store, - "row-inserted", - G_CALLBACK(queue_row_inserted), &ql_queue); - g_signal_connect(ql_queue.store, - "row-deleted", - G_CALLBACK(queue_row_deleted), &ql_queue); /* Catch keypresses */ g_signal_connect(ql_queue.view, "key-press-event", G_CALLBACK(queue_key_press), &ql_queue); diff --git a/doc/disorder.1.in b/doc/disorder.1.in index afa1eaa..b61a825 100644 --- a/doc/disorder.1.in +++ b/doc/disorder.1.in @@ -152,6 +152,23 @@ Add \fITRACKS\fR to the end of the queue. .B playing Report the currently playing track. .TP +.B playlist-del \fIPLAYLIST\fR +Deletes playlist \fIPLAYLIST\fR. +.TP +.B playlist-get \fIPLAYLIST\fR +Gets the contents of playlist \fIPLAYLIST\fR. +.TP +.B playlist-set \fIPLAYLIST\fR [\fIPATH\fR] +Set the contents of playlist \fIPLAYLIST\fR. +If an absolute path name is specified then the track list is read from +that filename. +Otherwise the track list is read from standard input. +In either case, the list is terminated either by end of file or by a line +containing a single ".". +.TP +.B playlists +Lists known playlists (in no particular order). +.TP .B prefs \fITRACK\fR Display all the preferences for \fITRACK\fR. See \fBdisorder_preferences\fR (5). diff --git a/doc/disorder_protocol.5.in b/doc/disorder_protocol.5.in index b42ebd5..a0baadb 100644 --- a/doc/disorder_protocol.5.in +++ b/doc/disorder_protocol.5.in @@ -38,6 +38,15 @@ that comments are prohibited. Bodies borrow their syntax from RFC821; they consist of zero or more ordinary lines, with any initial full stop doubled up, and are terminated by a line consisting of a full stop and a line feed. +.PP +Commands only have a body if explicitly stated below. +If they do have a body then the body should always be sent immediately; +unlike (for instance) the SMTP "DATA" command there is no intermediate step +where the server asks for the body to be sent. +.PP +Replies also only have a body if stated below. +The presence of a reply body can always be inferred from the response code; +if the last digit is a 3 then a body is present, otherwise it is not. .SH COMMANDS Commands always have a command name as the first field of the line; responses always have a 3-digit response code as the first field. @@ -47,8 +56,6 @@ All commands require the connection to have been already authenticated unless stated otherwise. If not stated otherwise, the \fBread\fR right is sufficient to execute the command. -.PP -Neither commands nor responses have a body unless stated otherwise. .TP .B adduser \fIUSERNAME PASSWORD \fR[\fIRIGHTS\fR] Create a new user with the given username and password. @@ -208,6 +215,43 @@ track information (see below). .IP If the response is \fB259\fR then nothing is playing. .TP +.B playlist-delete \fIPLAYLIST\fR +Delete a playlist. +Requires permission to modify that playlist and the \fBplay\fR right. +.TP +.B playlist-get \fIPLAYLIST\fR +Get the contents of a playlist, in a response body. +Requires permission to read that playlist and the \fBread\fR right. +.TP +.B playlist-get-share \fIPLAYLIST\fR +Get the sharing status of a playlist. +The result will be \fBpublic\fR, \fBprivate\fR or \fBshared\fR. +Requires permission to read that playlist and the \fBread\fR right. +.TP +.B playlist-lock \fIPLAYLIST\fR +Lock a playlist. +Requires permission to modify that playlist and the \fBplay\fR right. +Only one playlist may be locked at a time on a given connection and the lock +automatically expires when the connection is closed. +.TP +.B playlist-set \fIPLAYLIST\fR +Set the contents of a playlist. +The new contents should be supplied in a command body. +Requires permission to modify that playlist and the \fBplay\fR right. +The playlist must be locked. +.TP +.B playlist-set-share \fIPLAYLIST\fR \fISHARE\fR +Set the sharing status of a playlist to +\fBpublic\fR, \fBprivate\fR or \fBshared\fR. +Requires permission to modify that playlist and the \fBplay\fR right. +.TP +.B playlist-unlock\fR +Unlock the locked playlist. +.TP +.B playlists +List all playlists that this connection has permission to read. +Requires the \fBread\fR right. +.TP .B prefs \fBTRACK\fR Send back the preferences for \fITRACK\fR in a response body. Each line of the response has the usual line syntax, the first field being the @@ -598,6 +642,21 @@ Further details aren't included any more. .B playing \fITRACK\fR [\fIUSERNAME\fR] Started playing \fITRACK\fR. .TP +.B playlist_created \fIPLAYLIST\fR \fISHARING\fR +Sent when a playlist is created. +For private playlists this is intended to be sent only to the owner (but +this is not currently implemented). +.TP +.B playlist_deleted \fIPLAYLIST\fR +Sent when a playlist is deleted. +For private playlists this is intended to be sent only to the owner (but +this is not currently implemented). +.TP +.B playlist_modified \fIPLAYLIST\fR \fISHARING\fR +Sent when a playlist is modified (either its contents or its sharing status). +For private playlists this is intended to be sent only to the owner (but +this is not currently implemented). +.TP .B queue \fIQUEUE-ENTRY\fR... Added \fITRACK\fR to the queue. .TP diff --git a/lib/Makefile.am b/lib/Makefile.am index 4717fd6..d0890e1 100644 --- a/lib/Makefile.am +++ b/lib/Makefile.am @@ -78,6 +78,7 @@ libdisorder_a_SOURCES=charset.c charset.h \ table.c table.h \ timeval.h \ $(TRACKDB) trackdb.h trackdb-int.h \ + trackdb-playlists.c \ trackname.c trackorder.c trackname.h \ tracksort.c \ uaudio.c uaudio-thread.c uaudio.h uaudio-apis.c \ diff --git a/lib/client.c b/lib/client.c index 20db675..a0502eb 100644 --- a/lib/client.c +++ b/lib/client.c @@ -153,6 +153,8 @@ static int check_response(disorder_client *c, char **rp) { * @param c Client * @param rp Where to store result, or NULL * @param cmd Command + * @param body Body or NULL + * @param nbody Length of body or -1 * @param ap Arguments (UTF-8), terminated by (char *)0 * @return 0 on success, non-0 on error * @@ -163,10 +165,21 @@ static int check_response(disorder_client *c, char **rp) { * * NB that the response will NOT be converted to the local encoding * nor will quotes be stripped. See dequote(). + * + * If @p body is not NULL then the body is sent immediately after the + * command. @p nbody should be the number of lines or @c -1 to count + * them if @p body is NULL-terminated. + * + * Usually you would call this via one of the following interfaces: + * - disorder_simple() + * - disorder_simple_body() + * - disorder_simple_list() */ static int disorder_simple_v(disorder_client *c, char **rp, - const char *cmd, va_list ap) { + const char *cmd, + char **body, int nbody, + va_list ap) { const char *arg; struct dynstr d; @@ -185,13 +198,32 @@ static int disorder_simple_v(disorder_client *c, dynstr_append(&d, '\n'); dynstr_terminate(&d); D(("command: %s", d.vec)); - if(fputs(d.vec, c->fpout) < 0 || fflush(c->fpout)) { - byte_xasprintf((char **)&c->last, "write error: %s", strerror(errno)); - error(errno, "error writing to %s", c->ident); - return -1; + if(fputs(d.vec, c->fpout) < 0) + goto write_error; + if(body) { + if(nbody < 0) + for(nbody = 0; body[nbody]; ++nbody) + ; + for(int n = 0; n < nbody; ++n) { + if(body[n][0] == '.') + if(fputc('.', c->fpout) < 0) + goto write_error; + if(fputs(body[n], c->fpout) < 0) + goto write_error; + if(fputc('\n', c->fpout) < 0) + goto write_error; + } + if(fputs(".\n", c->fpout) < 0) + goto write_error; } + if(fflush(c->fpout)) + goto write_error; } return check_response(c, rp); +write_error: + byte_xasprintf((char **)&c->last, "write error: %s", strerror(errno)); + error(errno, "error writing to %s", c->ident); + return -1; } /** @brief Issue a command and parse a simple response @@ -218,7 +250,30 @@ static int disorder_simple(disorder_client *c, int ret; va_start(ap, cmd); - ret = disorder_simple_v(c, rp, cmd, ap); + ret = disorder_simple_v(c, rp, cmd, 0, 0, ap); + va_end(ap); + return ret; +} + +/** @brief Issue a command with a body and parse a simple response + * @param c Client + * @param rp Where to store result, or NULL (UTF-8) + * @param body Pointer to body + * @param nbody Size of body + * @param cmd Command + * @return 0 on success, non-0 on error + * + * See disorder_simple(). + */ +static int disorder_simple_body(disorder_client *c, + char **rp, + char **body, int nbody, + const char *cmd, ...) { + va_list ap; + int ret; + + va_start(ap, cmd); + ret = disorder_simple_v(c, rp, cmd, body, nbody, ap); va_end(ap); return ret; } @@ -670,6 +725,8 @@ static int readlist(disorder_client *c, char ***vecp, int *nvecp) { * *)0. They should be in UTF-8. * * 5xx responses count as errors. + * + * See disorder_simple(). */ static int disorder_simple_list(disorder_client *c, char ***vecp, int *nvecp, @@ -678,7 +735,7 @@ static int disorder_simple_list(disorder_client *c, int ret; va_start(ap, cmd); - ret = disorder_simple_v(c, 0, cmd, ap); + ret = disorder_simple_v(c, 0, cmd, 0, 0, ap); va_end(ap); if(ret) return ret; return readlist(c, vecp, nvecp); @@ -1311,6 +1368,103 @@ int disorder_adopt(disorder_client *c, const char *id) { return disorder_simple(c, 0, "adopt", id, (char *)0); } +/** @brief Delete a playlist + * @param c Client + * @param playlist Playlist to delete + * @return 0 on success, non-0 on error + */ +int disorder_playlist_delete(disorder_client *c, + const char *playlist) { + return disorder_simple(c, 0, "playlist-delete", playlist, (char *)0); +} + +/** @brief Get the contents of a playlist + * @param c Client + * @param playlist Playlist to get + * @param tracksp Where to put list of tracks + * @param ntracksp Where to put count of tracks + * @return 0 on success, non-0 on error + */ +int disorder_playlist_get(disorder_client *c, const char *playlist, + char ***tracksp, int *ntracksp) { + return disorder_simple_list(c, tracksp, ntracksp, + "playlist-get", playlist, (char *)0); +} + +/** @brief List all readable playlists + * @param c Client + * @param playlistsp Where to put list of playlists + * @param nplaylistsp Where to put count of playlists + * @return 0 on success, non-0 on error + */ +int disorder_playlists(disorder_client *c, + char ***playlistsp, int *nplaylistsp) { + return disorder_simple_list(c, playlistsp, nplaylistsp, + "playlists", (char *)0); +} + +/** @brief Get the sharing status of a playlist + * @param c Client + * @param playlist Playlist to inspect + * @param sharep Where to put sharing status + * @return 0 on success, non-0 on error + * + * Possible @p sharep values are @c public, @c private and @c shared. + */ +int disorder_playlist_get_share(disorder_client *c, const char *playlist, + char **sharep) { + return disorder_simple(c, sharep, + "playlist-get-share", playlist, (char *)0); +} + +/** @brief Get the sharing status of a playlist + * @param c Client + * @param playlist Playlist to modify + * @param share New sharing status + * @return 0 on success, non-0 on error + * + * Possible @p share values are @c public, @c private and @c shared. + */ +int disorder_playlist_set_share(disorder_client *c, const char *playlist, + const char *share) { + return disorder_simple(c, 0, + "playlist-set-share", playlist, share, (char *)0); +} + +/** @brief Lock a playlist for modifications + * @param c Client + * @param playlist Playlist to lock + * @return 0 on success, non-0 on error + */ +int disorder_playlist_lock(disorder_client *c, const char *playlist) { + return disorder_simple(c, 0, + "playlist-lock", playlist, (char *)0); +} + +/** @brief Unlock the locked playlist + * @param c Client + * @return 0 on success, non-0 on error + */ +int disorder_playlist_unlock(disorder_client *c) { + return disorder_simple(c, 0, + "playlist-unlock", (char *)0); +} + +/** @brief Set the contents of a playlst + * @param c Client + * @param playlist Playlist to modify + * @param tracks List of tracks + * @param ntracks Length of @p tracks (or -1 to count up to the first NULL) + * @return 0 on success, non-0 on error + */ +int disorder_playlist_set(disorder_client *c, + const char *playlist, + char **tracks, + int ntracks) { + return disorder_simple_body(c, 0, tracks, ntracks, + "playlist-set", playlist, (char *)0); +} + /* Local Variables: c-basic-offset:2 diff --git a/lib/client.h b/lib/client.h index 89f3037..f7ff728 100644 --- a/lib/client.h +++ b/lib/client.h @@ -132,6 +132,22 @@ int disorder_schedule_add(disorder_client *c, const char *action, ...); int disorder_adopt(disorder_client *c, const char *id); +int disorder_playlist_delete(disorder_client *c, + const char *playlist); +int disorder_playlist_get(disorder_client *c, const char *playlist, + char ***tracksp, int *ntracksp); +int disorder_playlists(disorder_client *c, + char ***playlistsp, int *nplaylists); +int disorder_playlist_get_share(disorder_client *c, const char *playlist, + char **sharep); +int disorder_playlist_set_share(disorder_client *c, const char *playlist, + const char *share); +int disorder_playlist_lock(disorder_client *c, const char *playlist); +int disorder_playlist_unlock(disorder_client *c); +int disorder_playlist_set(disorder_client *c, + const char *playlist, + char **tracks, + int ntracks); #endif /* CLIENT_H */ diff --git a/lib/configuration.c b/lib/configuration.c index dc3d009..ff59968 100644 --- a/lib/configuration.c +++ b/lib/configuration.c @@ -947,6 +947,8 @@ static const struct conf conf[] = { { C(password), &type_string, validate_any }, { C(pause_mode), &type_string, validate_pausemode }, { C(player), &type_stringlist_accum, validate_player }, + { C(playlist_lock_timeout), &type_integer, validate_positive }, + { C(playlist_max) , &type_integer, validate_positive }, { C(plugins), &type_string_accum, validate_isdir }, { C(prefsync), &type_integer, validate_positive }, { C(queue_pad), &type_integer, validate_positive }, @@ -1195,6 +1197,8 @@ static struct config *config_default(void) { c->new_bias_age = 7 * 86400; /* 1 week */ c->new_bias = 4500000; /* 50 times the base weight */ c->sox_generation = DEFAULT_SOX_GENERATION; + c->playlist_max = INT_MAX; /* effectively no limit */ + c->playlist_lock_timeout = 10; /* 10s */ /* Default stopwords */ if(config_set(&cs, (int)NDEFAULT_STOPWORDS, (char **)default_stopwords)) exit(1); diff --git a/lib/configuration.h b/lib/configuration.h index 875b9d6..9170a2f 100644 --- a/lib/configuration.h +++ b/lib/configuration.h @@ -185,6 +185,12 @@ struct config { /** @brief API used to play sound */ const char *api; + /** @brief Maximum size of a playlist */ + long playlist_max; + + /** @brief Maximum lifetime of a playlist lock */ + long playlist_lock_timeout; + /** @brief Home directory for state files */ const char *home; diff --git a/lib/eclient.c b/lib/eclient.c index 101a7bf..fad9e1b 100644 --- a/lib/eclient.c +++ b/lib/eclient.c @@ -92,6 +92,7 @@ typedef void operation_callback(disorder_eclient *c, struct operation *op); struct operation { struct operation *next; /**< @brief next operation */ char *cmd; /**< @brief command to send or 0 */ + char **body; /**< @brief command body */ operation_callback *opcallback; /**< @brief internal completion callback */ void (*completed)(); /**< @brief user completion callback or 0 */ void *v; /**< @brief data for COMPLETED */ @@ -165,6 +166,8 @@ static void stash_command(disorder_eclient *c, operation_callback *opcallback, void (*completed)(), void *v, + int nbody, + char **body, const char *cmd, ...); static void log_opcallback(disorder_eclient *c, struct operation *op); @@ -187,6 +190,9 @@ static void logentry_user_delete(disorder_eclient *c, int nvec, char **vec); static void logentry_user_edit(disorder_eclient *c, int nvec, char **vec); static void logentry_rights_changed(disorder_eclient *c, int nvec, char **vec); static void logentry_adopted(disorder_eclient *c, int nvec, char **vec); +static void logentry_playlist_created(disorder_eclient *c, int nvec, char **vec); +static void logentry_playlist_deleted(disorder_eclient *c, int nvec, char **vec); +static void logentry_playlist_modified(disorder_eclient *c, int nvec, char **vec); /* Tables ********************************************************************/ @@ -208,6 +214,9 @@ static const struct logentry_handler logentry_handlers[] = { LE(failed, 2, 2), LE(moved, 1, 1), LE(playing, 1, 2), + LE(playlist_created, 2, 2), + LE(playlist_deleted, 1, 1), + LE(playlist_modified, 2, 2), LE(queue, 2, INT_MAX), LE(recent_added, 2, INT_MAX), LE(recent_removed, 1, 1), @@ -326,6 +335,24 @@ static int protocol_error(disorder_eclient *c, struct operation *op, /* State machine *************************************************************/ +/** @brief Send an operation (into the output buffer) + * @param op Operation to send + */ +static void op_send(struct operation *op) { + disorder_eclient *const c = op->client; + put(c, op->cmd, strlen(op->cmd)); + if(op->body) { + for(int n = 0; op->body[n]; ++n) { + if(op->body[n][0] == '.') + put(c, ".", 1); + put(c, op->body[n], strlen(op->body[n])); + put(c, "\n", 1); + } + put(c, ".\n", 2); + } + op->sent = 1; +} + /** @brief Called when there's something to do * @param c Client * @param mode bitmap of @ref DISORDER_POLL_READ and/or @ref DISORDER_POLL_WRITE. @@ -379,7 +406,7 @@ void disorder_eclient_polled(disorder_eclient *c, unsigned mode) { D(("state_connected")); /* We just connected. Initiate the authentication protocol. */ stash_command(c, 1/*queuejump*/, authbanner_opcallback, - 0/*completed*/, 0/*v*/, 0/*cmd*/); + 0/*completed*/, 0/*v*/, -1/*nbody*/, 0/*body*/, 0/*cmd*/); /* We never stay is state_connected very long. We could in principle jump * straight to state_cmdresponse since there's actually no command to * send, but that would arguably be cheating. */ @@ -395,17 +422,13 @@ void disorder_eclient_polled(disorder_eclient *c, unsigned mode) { if(c->authenticated) { /* Transmit all unsent operations */ for(op = c->ops; op; op = op->next) { - if(!op->sent) { - put(c, op->cmd, strlen(op->cmd)); - op->sent = 1; - } + if(!op->sent) + op_send(op); } } else { /* Just send the head operation */ - if(c->ops->cmd && !c->ops->sent) { - put(c, c->ops->cmd, strlen(c->ops->cmd)); - c->ops->sent = 1; - } + if(c->ops->cmd && !c->ops->sent) + op_send(c->ops); } /* Awaiting response for the operation at the head of the list */ c->state = state_cmdresponse; @@ -601,6 +624,7 @@ static void authbanner_opcallback(disorder_eclient *c, return; } stash_command(c, 1/*queuejump*/, authuser_opcallback, 0/*completed*/, 0/*v*/, + -1/*nbody*/, 0/*body*/, "user", quoteutf8(config->username), quoteutf8(res), (char *)0); } @@ -625,6 +649,7 @@ static void authuser_opcallback(disorder_eclient *c, if(c->log_callbacks && !(c->ops && c->ops->opcallback == log_opcallback)) /* We are a log client, switch to logging mode */ stash_command(c, 0/*queuejump*/, log_opcallback, 0/*completed*/, c->log_v, + -1/*nbody*/, 0/*body*/, "log", (char *)0); } @@ -787,6 +812,8 @@ static void stash_command_vector(disorder_eclient *c, operation_callback *opcallback, void (*completed)(), void *v, + int nbody, + char **body, int ncmd, char **cmd) { struct operation *op = xmalloc(sizeof *op); @@ -805,6 +832,13 @@ static void stash_command_vector(disorder_eclient *c, op->cmd = d.vec; } else op->cmd = 0; /* usually, awaiting challenge */ + if(nbody >= 0) { + op->body = xcalloc(nbody + 1, sizeof (char *)); + for(n = 0; n < nbody; ++n) + op->body[n] = xstrdup(body[n]); + op->body[n] = 0; + } else + op->body = NULL; op->opcallback = opcallback; op->completed = completed; op->v = v; @@ -830,6 +864,8 @@ static void vstash_command(disorder_eclient *c, operation_callback *opcallback, void (*completed)(), void *v, + int nbody, + char **body, const char *cmd, va_list ap) { char *arg; struct vector vec; @@ -841,9 +877,11 @@ static void vstash_command(disorder_eclient *c, while((arg = va_arg(ap, char *))) vector_append(&vec, arg); stash_command_vector(c, queuejump, opcallback, completed, v, - vec.nvec, vec.vec); + nbody, body, vec.nvec, vec.vec); } else - stash_command_vector(c, queuejump, opcallback, completed, v, 0, 0); + stash_command_vector(c, queuejump, opcallback, completed, v, + nbody, body, + 0, 0); } static void stash_command(disorder_eclient *c, @@ -851,12 +889,14 @@ static void stash_command(disorder_eclient *c, operation_callback *opcallback, void (*completed)(), void *v, + int nbody, + char **body, const char *cmd, ...) { va_list ap; va_start(ap, cmd); - vstash_command(c, queuejump, opcallback, completed, v, cmd, ap); + vstash_command(c, queuejump, opcallback, completed, v, nbody, body, cmd, ap); va_end(ap); } @@ -1008,6 +1048,8 @@ static void list_response_opcallback(disorder_eclient *c, D(("list_response_callback")); if(c->rc / 100 == 2) completed(op->v, NULL, c->vec.nvec, c->vec.vec); + else if(c->rc == 555) + completed(op->v, NULL, -1, NULL); else completed(op->v, errorstring(c), 0, 0); } @@ -1039,7 +1081,24 @@ static int simple(disorder_eclient *c, va_list ap; va_start(ap, cmd); - vstash_command(c, 0/*queuejump*/, opcallback, completed, v, cmd, ap); + vstash_command(c, 0/*queuejump*/, opcallback, completed, v, -1, 0, cmd, ap); + va_end(ap); + /* Give the state machine a kick, since we might be in state_idle */ + disorder_eclient_polled(c, 0); + return 0; +} + +static int simple_body(disorder_eclient *c, + operation_callback *opcallback, + void (*completed)(), + void *v, + int nbody, + char **body, + const char *cmd, ...) { + va_list ap; + + va_start(ap, cmd); + vstash_command(c, 0/*queuejump*/, opcallback, completed, v, nbody, body, cmd, ap); va_end(ap); /* Give the state machine a kick, since we might be in state_idle */ disorder_eclient_polled(c, 0); @@ -1124,7 +1183,7 @@ int disorder_eclient_moveafter(disorder_eclient *c, for(n = 0; n < nids; ++n) vector_append(&vec, (char *)ids[n]); stash_command_vector(c, 0/*queuejump*/, no_response_opcallback, completed, v, - vec.nvec, vec.vec); + -1, 0, vec.nvec, vec.vec); disorder_eclient_polled(c, 0); return 0; } @@ -1420,6 +1479,123 @@ int disorder_eclient_adopt(disorder_eclient *c, "adopt", id, (char *)0); } +/** @brief Get the list of playlists + * @param c Client + * @param completed Called with list of playlists + * @param v Passed to @p completed + * + * The playlist list is not sorted in any particular order. + */ +int disorder_eclient_playlists(disorder_eclient *c, + disorder_eclient_list_response *completed, + void *v) { + return simple(c, list_response_opcallback, (void (*)())completed, v, + "playlists", (char *)0); +} + +/** @brief Delete a playlist + * @param c Client + * @param completed Called on completion + * @param playlist Playlist to delete + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_delete(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + void *v) { + return simple(c, no_response_opcallback, (void (*)())completed, v, + "playlist-delete", playlist, (char *)0); +} + +/** @brief Lock a playlist + * @param c Client + * @param completed Called on completion + * @param playlist Playlist to lock + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_lock(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + void *v) { + return simple(c, no_response_opcallback, (void (*)())completed, v, + "playlist-lock", playlist, (char *)0); +} + +/** @brief Unlock the locked a playlist + * @param c Client + * @param completed Called on completion + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_unlock(disorder_eclient *c, + disorder_eclient_no_response *completed, + void *v) { + return simple(c, no_response_opcallback, (void (*)())completed, v, + "playlist-unlock", (char *)0); +} + +/** @brief Set a playlist's sharing + * @param c Client + * @param completed Called on completion + * @param playlist Playlist to modify + * @param sharing @c "public" or @c "private" + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_set_share(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + const char *sharing, + void *v) { + return simple(c, no_response_opcallback, (void (*)())completed, v, + "playlist-set-share", playlist, sharing, (char *)0); +} + +/** @brief Get a playlist's sharing + * @param c Client + * @param completed Called with sharing status + * @param playlist Playlist to inspect + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_get_share(disorder_eclient *c, + disorder_eclient_string_response *completed, + const char *playlist, + void *v) { + return simple(c, string_response_opcallback, (void (*)())completed, v, + "playlist-get-share", playlist, (char *)0); +} + +/** @brief Set a playlist + * @param c Client + * @param completed Called on completion + * @param playlist Playlist to modify + * @param tracks List of tracks + * @param ntracks Number of tracks + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_set(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + char **tracks, + int ntracks, + void *v) { + return simple_body(c, no_response_opcallback, (void (*)())completed, v, + ntracks, tracks, + "playlist-set", playlist, (char *)0); +} + +/** @brief Get a playlist's contents + * @param c Client + * @param completed Called with playlist contents + * @param playlist Playlist to inspect + * @param v Passed to @p completed + */ +int disorder_eclient_playlist_get(disorder_eclient *c, + disorder_eclient_list_response *completed, + const char *playlist, + void *v) { + return simple(c, list_response_opcallback, (void (*)())completed, v, + "playlist-get", playlist, (char *)0); +} + /* Log clients ***************************************************************/ /** @brief Monitor the server log @@ -1444,7 +1620,7 @@ int disorder_eclient_log(disorder_eclient *c, if(c->log_callbacks->state) c->log_callbacks->state(c->log_v, c->statebits); stash_command(c, 0/*queuejump*/, log_opcallback, 0/*completed*/, v, - "log", (char *)0); + -1, 0, "log", (char *)0); disorder_eclient_polled(c, 0); return 0; } @@ -1612,6 +1788,27 @@ static void logentry_rights_changed(disorder_eclient *c, } } +static void logentry_playlist_created(disorder_eclient *c, + int attribute((unused)) nvec, + char **vec) { + if(c->log_callbacks->playlist_created) + c->log_callbacks->playlist_created(c->log_v, vec[0], vec[1]); +} + +static void logentry_playlist_deleted(disorder_eclient *c, + int attribute((unused)) nvec, + char **vec) { + if(c->log_callbacks->playlist_deleted) + c->log_callbacks->playlist_deleted(c->log_v, vec[0]); +} + +static void logentry_playlist_modified(disorder_eclient *c, + int attribute((unused)) nvec, + char **vec) { + if(c->log_callbacks->playlist_modified) + c->log_callbacks->playlist_modified(c->log_v, vec[0], vec[1]); +} + static const struct { unsigned long bit; const char *enable; diff --git a/lib/eclient.h b/lib/eclient.h index ce5c582..fae610c 100644 --- a/lib/eclient.h +++ b/lib/eclient.h @@ -1,6 +1,6 @@ /* * This file is part of DisOrder. - * Copyright (C) 2006, 2007 Richard Kettlewell + * Copyright (C) 2006-2008 Richard Kettlewell * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -168,6 +168,15 @@ typedef struct disorder_eclient_log_callbacks { /** @brief Called when a track is adopted */ void (*adopted)(void *v, const char *id, const char *who); + + /** @brief Called when a new playlist is created */ + void (*playlist_created)(void *v, const char *playlist, const char *sharing); + + /** @brief Called when a playlist is modified */ + void (*playlist_modified)(void *v, const char *playlist, const char *sharing); + + /** @brief Called when a new playlist is deleted */ + void (*playlist_deleted)(void *v, const char *playlist); } disorder_eclient_log_callbacks; /* State bits */ @@ -222,7 +231,8 @@ typedef void disorder_eclient_no_response(void *v, * * @p error will be NULL on success. In this case @p value will be the result * (which might be NULL for disorder_eclient_get(), - * disorder_eclient_get_global() and disorder_eclient_userinfo()). + * disorder_eclient_get_global(), disorder_eclient_userinfo() and + * disorder_eclient_playlist_get_share()). * * @p error will be non-NULL on failure. In this case @p value is always NULL. */ @@ -281,7 +291,8 @@ typedef void disorder_eclient_queue_response(void *v, * @param vec Pointer to response list * * @p error will be NULL on success. In this case @p nvec and @p vec will give - * the result. + * the result, or be -1 and NULL respectively e.g. from + * disorder_eclient_playlist_get() if there is no such playlist. * * @p error will be non-NULL on failure. In this case @p nvec and @p vec will * be 0 and NULL. @@ -490,6 +501,40 @@ int disorder_eclient_adopt(disorder_eclient *c, disorder_eclient_no_response *completed, const char *id, void *v); +int disorder_eclient_playlists(disorder_eclient *c, + disorder_eclient_list_response *completed, + void *v); +int disorder_eclient_playlist_delete(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + void *v); +int disorder_eclient_playlist_lock(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + void *v); +int disorder_eclient_playlist_unlock(disorder_eclient *c, + disorder_eclient_no_response *completed, + void *v); +int disorder_eclient_playlist_set_share(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + const char *sharing, + void *v); +int disorder_eclient_playlist_get_share(disorder_eclient *c, + disorder_eclient_string_response *completed, + const char *playlist, + void *v); +int disorder_eclient_playlist_set(disorder_eclient *c, + disorder_eclient_no_response *completed, + const char *playlist, + char **tracks, + int ntracks, + void *v); +int disorder_eclient_playlist_get(disorder_eclient *c, + disorder_eclient_list_response *completed, + const char *playlist, + void *v); + #endif /* diff --git a/lib/trackdb-int.h b/lib/trackdb-int.h index 6d5234e..b21b5d9 100644 --- a/lib/trackdb-int.h +++ b/lib/trackdb-int.h @@ -22,6 +22,7 @@ #include +#include "trackdb.h" #include "kvp.h" struct vector; /* forward declaration */ @@ -36,6 +37,7 @@ extern DB *trackdb_noticeddb; extern DB *trackdb_globaldb; extern DB *trackdb_usersdb; extern DB *trackdb_scheduledb; +extern DB *trackdb_playlistsdb; DBC *trackdb_opencursor(DB *db, DB_TXN *tid); /* open a transaction */ @@ -151,6 +153,7 @@ int trackdb_get_global_tid(const char *name, char **parsetags(const char *s); int tag_intersection(char **a, char **b); +int valid_username(const char *user); #endif /* TRACKDB_INT_H */ diff --git a/lib/trackdb-playlists.c b/lib/trackdb-playlists.c new file mode 100644 index 0000000..bef7107 --- /dev/null +++ b/lib/trackdb-playlists.c @@ -0,0 +1,491 @@ +/* + * 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/trackdb-playlists.c + * @brief Track database playlist support + * + * This file implements reading and modification of playlists, including access + * control, but not locking or event logging (at least yet). + */ +#include "common.h" + +#include + +#include "trackdb-int.h" +#include "mem.h" +#include "log.h" +#include "configuration.h" +#include "vector.h" +#include "eventlog.h" + +static int trackdb_playlist_get_tid(const char *name, + const char *who, + char ***tracksp, + int *ntracksp, + char **sharep, + DB_TXN *tid); +static int trackdb_playlist_set_tid(const char *name, + const char *who, + char **tracks, + int ntracks, + const char *share, + DB_TXN *tid); +static int trackdb_playlist_list_tid(const char *who, + char ***playlistsp, + int *nplaylistsp, + DB_TXN *tid); +static int trackdb_playlist_delete_tid(const char *name, + const char *who, + DB_TXN *tid); + +/** @brief Parse a playlist name + * @param name Playlist name + * @param ownerp Where to put owner, or NULL + * @param sharep Where to put default sharing, or NULL + * @return 0 on success, -1 on error + * + * Playlists take the form USER.PLAYLIST or just PLAYLIST. The PLAYLIST part + * is alphanumeric and nonempty. USER is a username (see valid_username()). + */ +int playlist_parse_name(const char *name, + char **ownerp, + char **sharep) { + const char *dot = strchr(name, '.'), *share; + char *owner; + + if(dot) { + /* Owned playlist */ + owner = xstrndup(name, dot - name); + if(!valid_username(owner)) + return -1; + if(!valid_username(dot + 1)) + return -1; + share = "private"; + } else { + /* Shared playlist */ + if(!valid_username(name)) + return -1; + owner = 0; + share = "shared"; + } + if(ownerp) + *ownerp = owner; + if(sharep) + *sharep = xstrdup(share); + return 0; +} + +/** @brief Check read access rights + * @param name Playlist name + * @param who Who wants to read + * @param share Playlist share status + */ +static int playlist_may_read(const char *name, + const char *who, + const char *share) { + char *owner; + + if(playlist_parse_name(name, &owner, 0)) + return 0; + /* Anyone can read shared playlists */ + if(!owner) + return 1; + /* You can always read playlists you own */ + if(!strcmp(owner, who)) + return 1; + /* You can read public playlists */ + if(!strcmp(share, "public")) + return 1; + /* Anything else is prohibited */ + return 0; +} + +/** @brief Check modify access rights + * @param name Playlist name + * @param who Who wants to modify + * @param share Playlist share status + */ +static int playlist_may_write(const char *name, + const char *who, + const char attribute((unused)) *share) { + char *owner; + + if(playlist_parse_name(name, &owner, 0)) + return 0; + /* Anyone can modify shared playlists */ + if(!owner) + return 1; + /* You can always modify playlists you own */ + if(!strcmp(owner, who)) + return 1; + /* Anything else is prohibited */ + return 0; +} + +/** @brief Get playlist data + * @param name Name of playlist + * @param who Who wants to know + * @param tracksp Where to put list of tracks, or NULL + * @param ntracksp Where to put count of tracks, or NULL + * @param sharep Where to put sharing type, or NULL + * @return 0 on success, non-0 on error + * + * Possible return values: + * - @c 0 on success + * - @c ENOENT if the playlist doesn't exist + * - @c EINVAL if the playlist name is invalid + * - @c EACCES if the playlist cannot be read by @p who + */ +int trackdb_playlist_get(const char *name, + const char *who, + char ***tracksp, + int *ntracksp, + char **sharep) { + int e; + + if(playlist_parse_name(name, 0, 0)) { + error(0, "invalid playlist name '%s'", name); + return EINVAL; + } + WITH_TRANSACTION(trackdb_playlist_get_tid(name, who, + tracksp, ntracksp, sharep, + tid)); + /* Don't expose libdb error codes too much */ + if(e == DB_NOTFOUND) + e = ENOENT; + return e; +} + +static int trackdb_playlist_get_tid(const char *name, + const char *who, + char ***tracksp, + int *ntracksp, + char **sharep, + DB_TXN *tid) { + struct kvp *k; + int e, ntracks; + const char *s; + + if((e = trackdb_getdata(trackdb_playlistsdb, name, &k, tid))) + return e; + /* Get sharability */ + if(!(s = kvp_get(k, "sharing"))) { + error(0, "playlist '%s' has no 'sharing' key", name); + s = "private"; + } + /* Check the read is allowed */ + if(!playlist_may_read(name, who, s)) + return EACCES; + /* Return sharability */ + if(sharep) + *sharep = xstrdup(s); + /* Get track count */ + if(!(s = kvp_get(k, "count"))) { + error(0, "playlist '%s' has no 'count' key", name); + s = "0"; + } + ntracks = atoi(s); + if(ntracks < 0) { + error(0, "playlist '%s' has negative count", name); + ntracks = 0; + } + /* Return track count */ + if(ntracksp) + *ntracksp = ntracks; + if(tracksp) { + /* Get track list */ + char **tracks = xcalloc(ntracks + 1, sizeof (char *)); + char b[16]; + + for(int n = 0; n < ntracks; ++n) { + snprintf(b, sizeof b, "%d", n); + if(!(s = kvp_get(k, b))) { + error(0, "playlist '%s' lacks track %d", name, n); + s = "unknown"; + } + tracks[n] = xstrdup(s); + } + tracks[ntracks] = 0; + /* Return track list */ + *tracksp = tracks; + } + return 0; +} + +/** @brief Modify or create a playlist + * @param name Playlist name + * @param tracks List of tracks to set, or NULL to leave alone + * @param ntracks Length of @p tracks + * @param share Sharing status, or NULL to leave alone + * @return 0 on success, non-0 on error + * + * If the playlist exists it is just modified. + * + * If the playlist does not exist it is created. The default set of tracks is + * none, and the default sharing is private (if it is an owned one) or shared + * (otherwise). + * + * If neither @c tracks nor @c share are set then we only do an access check. + * The database is never modified (even to create the playlist) in this + * situation. + * + * Possible return values: + * - @c 0 on success + * - @c EINVAL if the playlist name is invalid + * - @c EACCES if the playlist cannot be modified by @p who + */ +int trackdb_playlist_set(const char *name, + const char *who, + char **tracks, + int ntracks, + const char *share) { + int e; + char *owner; + + if(playlist_parse_name(name, &owner, 0)) { + error(0, "invalid playlist name '%s'", name); + return EINVAL; + } + /* Check valid share types */ + if(share) { + if(owner) { + /* Playlists with an owner must be public or private */ + if(strcmp(share, "public") + && strcmp(share, "private")) { + error(0, "playlist '%s' must be public or private", name); + return EINVAL; + } + } else { + /* Playlists with no owner must be shared */ + if(strcmp(share, "shared")) { + error(0, "playlist '%s' must be shared", name); + return EINVAL; + } + } + } + /* We've checked as much as we can for now, now go and attempt the change */ + WITH_TRANSACTION(trackdb_playlist_set_tid(name, who, tracks, ntracks, share, + tid)); + return e; +} + +static int trackdb_playlist_set_tid(const char *name, + const char *who, + char **tracks, + int ntracks, + const char *share, + DB_TXN *tid) { + struct kvp *k; + int e; + const char *s; + const char *event = "playlist_modified"; + + if((e = trackdb_getdata(trackdb_playlistsdb, name, &k, tid)) + && e != DB_NOTFOUND) + return e; + /* If the playlist doesn't exist set some defaults */ + if(e == DB_NOTFOUND) { + char *defshare, *owner; + + if(playlist_parse_name(name, &owner, &defshare)) + return EINVAL; + /* Can't create a non-shared playlist belonging to someone else. In fact + * this should be picked up by playlist_may_write() below but it's clearer + * to do it here. */ + if(owner && strcmp(owner, who)) + return EACCES; + k = 0; + kvp_set(&k, "count", 0); + kvp_set(&k, "sharing", defshare); + event = "playlist_created"; + } + /* Check that the modification is allowed */ + if(!(s = kvp_get(k, "sharing"))) { + error(0, "playlist '%s' has no 'sharing' key", name); + s = "private"; + } + if(!playlist_may_write(name, who, s)) + return EACCES; + /* If no change was requested then don't even create */ + if(!share && !tracks) + return 0; + /* Set the new values */ + if(share) + kvp_set(&k, "sharing", share); + if(tracks) { + char b[16]; + int oldcount, n; + + /* Sanity check track count */ + if(ntracks < 0 || ntracks > config->playlist_max) { + error(0, "invalid track count %d", ntracks); + return EINVAL; + } + /* Set the tracks */ + for(n = 0; n < ntracks; ++n) { + snprintf(b, sizeof b, "%d", n); + kvp_set(&k, b, tracks[n]); + } + /* Get the old track count */ + if((s = kvp_get(k, "count"))) + oldcount = atoi(s); + else + oldcount = 0; + /* Delete old slots */ + for(; n < oldcount; ++n) { + snprintf(b, sizeof b, "%d", n); + kvp_set(&k, b, NULL); + } + /* Set the new count */ + snprintf(b, sizeof b, "%d", ntracks); + kvp_set(&k, "count", b); + } + /* Store the resulting record */ + e = trackdb_putdata(trackdb_playlistsdb, name, k, tid, 0); + /* Log the event */ + if(!e) + eventlog(event, name, kvp_get(k, "sharing"), (char *)0); + return e; +} + +/** @brief Get a list of playlists + * @param who Who wants to know + * @param playlistsp Where to put list of playlists + * @param nplaylistsp Where to put count of playlists, or NULL + */ +void trackdb_playlist_list(const char *who, + char ***playlistsp, + int *nplaylistsp) { + int e; + + WITH_TRANSACTION(trackdb_playlist_list_tid(who, playlistsp, nplaylistsp, + tid)); +} + +static int trackdb_playlist_list_tid(const char *who, + char ***playlistsp, + int *nplaylistsp, + DB_TXN *tid) { + struct vector v[1]; + DBC *c; + DBT k[1], d[1]; + int e; + + vector_init(v); + c = trackdb_opencursor(trackdb_playlistsdb, tid); + memset(k, 0, sizeof k); + while(!(e = c->c_get(c, k, prepare_data(d), DB_NEXT))) { + char *name = xstrndup(k->data, k->size), *owner; + const char *share = kvp_get(kvp_urldecode(d->data, d->size), + "sharing"); + + /* Extract owner; malformed names are skipped */ + if(playlist_parse_name(name, &owner, 0)) { + error(0, "invalid playlist name '%s' found in database", name); + continue; + } + if(!share) { + error(0, "playlist '%s' has no 'sharing' key", name); + continue; + } + /* Always list public and shared playlists + * Only list private ones to their owner + * Don't list anything else + */ + if(!strcmp(share, "public") + || !strcmp(share, "shared") + || (!strcmp(share, "private") + && owner && !strcmp(owner, who))) + vector_append(v, name); + } + trackdb_closecursor(c); + switch(e) { + case DB_NOTFOUND: + break; + case DB_LOCK_DEADLOCK: + return e; + default: + fatal(0, "c->c_get: %s", db_strerror(e)); + } + vector_terminate(v); + if(playlistsp) + *playlistsp = v->vec; + if(nplaylistsp) + *nplaylistsp = v->nvec; + return 0; +} + +/** @brief Delete a playlist + * @param name Playlist name + * @param who Who is deleting it + * @return 0 on success, non-0 on error + * + * Possible return values: + * - @c 0 on success + * - @c EINVAL if the playlist name is invalid + * - @c EACCES if the playlist cannot be modified by @p who + * - @c ENOENT if the playlist doesn't exist + */ +int trackdb_playlist_delete(const char *name, + const char *who) { + int e; + char *owner; + + if(playlist_parse_name(name, &owner, 0)) { + error(0, "invalid playlist name '%s'", name); + return EINVAL; + } + /* We've checked as much as we can for now, now go and attempt the change */ + WITH_TRANSACTION(trackdb_playlist_delete_tid(name, who, tid)); + if(e == DB_NOTFOUND) + e = ENOENT; + return e; +} + +static int trackdb_playlist_delete_tid(const char *name, + const char *who, + DB_TXN *tid) { + struct kvp *k; + int e; + const char *s; + + if((e = trackdb_getdata(trackdb_playlistsdb, name, &k, tid))) + return e; + /* Check that modification is allowed */ + if(!(s = kvp_get(k, "sharing"))) { + error(0, "playlist '%s' has no 'sharing' key", name); + s = "private"; + } + if(!playlist_may_write(name, who, s)) + return EACCES; + /* Delete the playlist */ + e = trackdb_delkey(trackdb_playlistsdb, name, tid); + if(!e) + eventlog("playlist_deleted", name, 0); + return e; +} + +/* +Local Variables: +c-basic-offset:2 +comment-column:40 +fill-column:79 +indent-tabs-mode:nil +End: +*/ diff --git a/lib/trackdb.c b/lib/trackdb.c index 6984b21..d724472 100644 --- a/lib/trackdb.c +++ b/lib/trackdb.c @@ -157,6 +157,13 @@ DB *trackdb_scheduledb; */ DB *trackdb_usersdb; +/** @brief The playlists database + * - Keys are playlist names + * - Values are encoded key-value pairs + * - Data is user data and cannot be reconstructed + */ +DB *trackdb_playlistsdb; + static pid_t db_deadlock_pid = -1; /* deadlock manager PID */ static pid_t rescan_pid = -1; /* rescanner PID */ static int initialized, opened; /* state */ @@ -472,6 +479,7 @@ void trackdb_open(int flags) { trackdb_noticeddb = open_db("noticed.db", DB_DUPSORT, DB_BTREE, dbflags, 0666); trackdb_scheduledb = open_db("schedule.db", 0, DB_HASH, dbflags, 0666); + trackdb_playlistsdb = open_db("playlists.db", 0, DB_HASH, dbflags, 0666); if(!trackdb_existing_database && !(flags & TRACKDB_READ_ONLY)) { /* Stash the database version */ char buf[32]; @@ -503,6 +511,7 @@ void trackdb_close(void) { CLOSE("noticed.db", trackdb_noticeddb); CLOSE("schedule.db", trackdb_scheduledb); CLOSE("users.db", trackdb_usersdb); + CLOSE("playlists.db", trackdb_playlistsdb); D(("closed databases")); } @@ -2553,8 +2562,10 @@ static int trusted(const char *user) { * Currently we only allow the letters and digits in ASCII. We could be more * liberal than this but it is a nice simple test. It is critical that * semicolons are never allowed. + * + * NB also used by playlist_parse_name() to validate playlist names! */ -static int valid_username(const char *user) { +int valid_username(const char *user) { if(!*user) return 0; while(*user) { diff --git a/lib/trackdb.h b/lib/trackdb.h index 0774579..6b86651 100644 --- a/lib/trackdb.h +++ b/lib/trackdb.h @@ -184,6 +184,25 @@ void trackdb_add_rescanned(void (*rescanned)(void *ru), void *ru); int trackdb_rescan_underway(void); +int playlist_parse_name(const char *name, + char **ownerp, + char **sharep); +int trackdb_playlist_get(const char *name, + const char *who, + char ***tracksp, + int *ntracksp, + char **sharep); +int trackdb_playlist_set(const char *name, + const char *who, + char **tracks, + int ntracks, + const char *share); +void trackdb_playlist_list(const char *who, + char ***playlistsp, + int *nplaylistsp); +int trackdb_playlist_delete(const char *name, + const char *who); + #endif /* TRACKDB_H */ /* diff --git a/python/disorder.py.in b/python/disorder.py.in index f6fe1a4..d06c7ee 100644 --- a/python/disorder.py.in +++ b/python/disorder.py.in @@ -125,8 +125,8 @@ class operationError(Error): self.cmd_ = cmd self.details_ = details def __str__(self): - """Return the complete response string from the server, with the command - if available. + """Return the complete response string from the server, with the + command if available. Excludes the final newline. """ @@ -433,8 +433,8 @@ class client: Returns the ID of the new queue entry. - Note that queue IDs are unicode strings (because all track information - values are unicode strings). + Note that queue IDs are unicode strings (because all track + information values are unicode strings). """ res, details = self._simple("play", track) return unicode(details) # because it's unicode in queue() output @@ -539,8 +539,8 @@ class client: The return value is a list of dictionaries corresponding to recently played tracks. The next track to be played comes first. - See disorder_protocol(5) for the meanings of the keys. All keys are - plain strings but the values will be unicode strings.""" + See disorder_protocol(5) for the meanings of the keys. + All keys are plain strings but the values will be unicode strings.""" return self._somequeue("queue") def _somedir(self, command, dir, re): @@ -775,7 +775,8 @@ class client: The callback should return True to continue or False to stop (don't forget this, or your program will mysteriously misbehave). Once you - stop reading the log the connection is useless and should be deleted. + stop reading the log the connection is useless and should be + deleted. It is suggested that you use the disorder.monitor class instead of calling this method directly, but this is not mandatory. @@ -902,7 +903,8 @@ class client: self._simple("schedule-del", event) def schedule_get(self, event): - """Get the details for an event as a dict (returns None if event not found)""" + """Get the details for an event as a dict (returns None if + event not found)""" res, details = self._simple("schedule-get", event) if res == 555: return None @@ -920,6 +922,54 @@ class client: """Adopt a randomly picked track""" self._simple("adopt", id) + def playlist_delete(self, playlist): + """Delete a playlist""" + res, details = self._simple("playlist-delete", playlist) + if res == 555: + raise operationError(res, details, "playlist-delete") + + def playlist_get(self, playlist): + """Get the contents of a playlist + + The return value is an array of track names, or None if there is no + such playlist.""" + res, details = self._simple("playlist-get", playlist) + if res == 555: + return None + return self._body() + + def playlist_lock(self, playlist): + """Lock a playlist. Playlists can only be modified when locked.""" + self._simple("playlist-lock", playlist) + + def playlist_unlock(self): + """Unlock the locked playlist.""" + self._simple("playlist-unlock") + + def playlist_set(self, playlist, tracks): + """Set the contents of a playlist. The playlist must be locked. + + Arguments: + playlist -- Playlist to set + tracks -- Array of tracks""" + self._simple_body(tracks, "playlist-set", playlist) + + def playlist_set_share(self, playlist, share): + """Set the sharing status of a playlist""" + self._simple("playlist-set-share", playlist, share) + + def playlist_get_share(self, playlist): + """Returns the sharing status of a playlist""" + res, details = self._simple("playlist-get-share", playlist) + if res == 555: + return None + return _split(details)[0] + + def playlists(self): + """Returns the list of visible playlists""" + self._simple("playlists") + return self._body() + ######################################################################## # I/O infrastructure @@ -949,8 +999,8 @@ class client: else: raise protocolError(self.who, "invalid response %s") - def _send(self, *command): - # Quote and send a command + def _send(self, body, *command): + # Quote and send a command and optional body # # Returns the encoded command. quoted = _quote(command) @@ -959,6 +1009,13 @@ class client: try: self.w.write(encoded) self.w.write("\n") + if body != None: + for l in body: + if l[0] == ".": + self.w.write(".") + self.w.write(l) + self.w.write("\n") + self.w.write(".\n") self.w.flush() return encoded except IOError, e: @@ -969,7 +1026,7 @@ class client: self._disconnect() raise - def _simple(self, *command): + def _simple(self, *command): # Issue a simple command, throw an exception on error # # If an I/O error occurs, disconnect from the server. @@ -977,10 +1034,20 @@ class client: # On success or 'normal' errors returns response as a (code, details) tuple # # On error raise operationError + return self._simple_body(None, *command) + + def _simple_body(self, body, *command): + # Issue a simple command with optional body, throw an exception on error + # + # If an I/O error occurs, disconnect from the server. + # + # On success or 'normal' errors returns response as a (code, details) tuple + # + # On error raise operationError if self.state == 'disconnected': self.connect() if command: - cmd = self._send(*command) + cmd = self._send(body, *command) else: cmd = None res, details = self._response() @@ -1061,8 +1128,8 @@ class client: class monitor: """DisOrder event log monitor class - Intended to be subclassed with methods corresponding to event log messages - the implementor cares about over-ridden.""" + Intended to be subclassed with methods corresponding to event log + messages the implementor cares about over-ridden.""" def __init__(self, c=None): """Constructor for the monitor class @@ -1078,8 +1145,8 @@ class monitor: def run(self): """Start monitoring logs. Continues monitoring until one of the - message-specific methods returns False. Can be called more than once - (but not recursively!)""" + message-specific methods returns False. Can be called more than + once (but not recursively!)""" self.c.log(self._callback) def when(self): diff --git a/scripts/completion.bash b/scripts/completion.bash index 89eb63d..2766b17 100644 --- a/scripts/completion.bash +++ b/scripts/completion.bash @@ -33,6 +33,7 @@ complete -o default \ setup-guest schedule-del schedule-list schedule-set-global schedule-unset-global schedule-play adopt + playlist-del playlist-get playlist-set playlists -h --help -H --help-commands --version -V --config -c --length --debug -d" \ disorder diff --git a/server/dump.c b/server/dump.c index 4b023aa..1cee366 100644 --- a/server/dump.c +++ b/server/dump.c @@ -29,8 +29,6 @@ static const struct option options[] = { { "debug", no_argument, 0, 'D' }, { "recover", no_argument, 0, 'r' }, { "recover-fatal", no_argument, 0, 'R' }, - { "trackdb", no_argument, 0, 't' }, - { "searchdb", no_argument, 0, 's' }, { "recompute-aliases", no_argument, 0, 'a' }, { "remove-pathless", no_argument, 0, 'P' }, { 0, 0, 0, 0 } @@ -55,14 +53,70 @@ static void help(void) { exit(0); } +/** @brief Dump one record + * @param s Output stream + * @param tag Tag for error messages + * @param letter Prefix leter for dumped record + * @param dbname Database name + * @param db Database handle + * @param tid Transaction handle + * @return 0 or @c DB_LOCK_DEADLOCK + */ +static int dump_one(struct sink *s, + const char *tag, + int letter, + const char *dbname, + DB *db, + DB_TXN *tid) { + int err; + DBC *cursor; + DBT k, d; + + /* dump the preferences */ + cursor = trackdb_opencursor(db, tid); + err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), + DB_FIRST); + while(err == 0) { + if(sink_writec(s, letter) < 0 + || urlencode(s, k.data, k.size) + || sink_writec(s, '\n') < 0 + || urlencode(s, d.data, d.size) + || sink_writec(s, '\n') < 0) + fatal(errno, "error writing to %s", tag); + err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), + DB_NEXT); + } + switch(err) { + case DB_LOCK_DEADLOCK: + trackdb_closecursor(cursor); + return err; + case DB_NOTFOUND: + return trackdb_closecursor(cursor); + case 0: + assert(!"cannot happen"); + default: + fatal(0, "error reading %s: %s", dbname, db_strerror(err)); + } +} + +static struct { + int letter; + const char *dbname; + DB **db; +} dbtable[] = { + { 'P', "prefs.db", &trackdb_prefsdb }, + { 'G', "global.db", &trackdb_globaldb }, + { 'U', "users.db", &trackdb_usersdb }, + { 'W', "schedule.db", &trackdb_scheduledb }, + { 'L', "playlists.db", &trackdb_playlistsdb }, + /* avoid 'T' and 'S' for now */ +}; +#define NDBTABLE (sizeof dbtable / sizeof *dbtable) + /* dump prefs to FP, return nonzero on error */ -static void do_dump(FILE *fp, const char *tag, - int tracksdb, int searchdb) { - DBC *cursor = 0; +static void do_dump(FILE *fp, const char *tag) { DB_TXN *tid; struct sink *s = sink_stdio(tag, fp); - int err; - DBT k, d; for(;;) { tid = trackdb_begin_transaction(); @@ -72,124 +126,18 @@ static void do_dump(FILE *fp, const char *tag, fatal(errno, "error calling fflush"); if(ftruncate(fileno(fp), 0) < 0) fatal(errno, "error calling ftruncate"); - if(fprintf(fp, "V%c\n", (tracksdb || searchdb) ? '1' : '0') < 0) + if(fprintf(fp, "V0") < 0) fatal(errno, "error writing to %s", tag); - /* dump the preferences */ - cursor = trackdb_opencursor(trackdb_prefsdb, tid); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_FIRST); - while(err == 0) { - if(fputc('P', fp) < 0 - || urlencode(s, k.data, k.size) - || fputc('\n', fp) < 0 - || urlencode(s, d.data, d.size) - || fputc('\n', fp) < 0) - fatal(errno, "error writing to %s", tag); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_NEXT); - } - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } - cursor = 0; - - /* dump the global preferences */ - cursor = trackdb_opencursor(trackdb_globaldb, tid); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_FIRST); - while(err == 0) { - if(fputc('G', fp) < 0 - || urlencode(s, k.data, k.size) - || fputc('\n', fp) < 0 - || urlencode(s, d.data, d.size) - || fputc('\n', fp) < 0) - fatal(errno, "error writing to %s", tag); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_NEXT); - } - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } - cursor = 0; + for(size_t n = 0; n < NDBTABLE; ++n) + if(dump_one(s, tag, + dbtable[n].letter, dbtable[n].dbname, *dbtable[n].db, + tid)) + goto fail; - /* dump the users */ - cursor = trackdb_opencursor(trackdb_usersdb, tid); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_FIRST); - while(err == 0) { - if(fputc('U', fp) < 0 - || urlencode(s, k.data, k.size) - || fputc('\n', fp) < 0 - || urlencode(s, d.data, d.size) - || fputc('\n', fp) < 0) - fatal(errno, "error writing to %s", tag); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_NEXT); - } - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } - cursor = 0; - - /* dump the schedule */ - cursor = trackdb_opencursor(trackdb_scheduledb, tid); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_FIRST); - while(err == 0) { - if(fputc('W', fp) < 0 - || urlencode(s, k.data, k.size) - || fputc('\n', fp) < 0 - || urlencode(s, d.data, d.size) - || fputc('\n', fp) < 0) - fatal(errno, "error writing to %s", tag); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_NEXT); - } - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } - cursor = 0; - - - if(tracksdb) { - cursor = trackdb_opencursor(trackdb_tracksdb, tid); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_FIRST); - while(err == 0) { - if(fputc('T', fp) < 0 - || urlencode(s, k.data, k.size) - || fputc('\n', fp) < 0 - || urlencode(s, d.data, d.size) - || fputc('\n', fp) < 0) - fatal(errno, "error writing to %s", tag); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_NEXT); - } - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } - cursor = 0; - } - - if(searchdb) { - cursor = trackdb_opencursor(trackdb_searchdb, tid); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_FIRST); - while(err == 0) { - if(fputc('S', fp) < 0 - || urlencode(s, k.data, k.size) - || fputc('\n', fp) < 0 - || urlencode(s, d.data, d.size) - || fputc('\n', fp) < 0) - fatal(errno, "error writing to %s", tag); - err = cursor->c_get(cursor, prepare_data(&k), prepare_data(&d), - DB_NEXT); - } - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } cursor = 0; - } - - if(fputs("E\n", fp) < 0) fatal(errno, "error writing to %s", tag); - if(err == DB_LOCK_DEADLOCK) { - error(0, "c->c_get: %s", db_strerror(err)); - goto fail; - } - if(err && err != DB_NOTFOUND) - fatal(0, "cursor->c_get: %s", db_strerror(err)); - if(trackdb_closecursor(cursor)) { cursor = 0; goto fail; } + if(fputs("E\n", fp) < 0) + fatal(errno, "error writing to %s", tag); break; fail: - trackdb_closecursor(cursor); - cursor = 0; info("aborting transaction and retrying dump"); trackdb_abort_transaction(tid); } @@ -276,9 +224,6 @@ static int undump_dbt(FILE *fp, const char *tag, DBT *dbt) { /* undump from FP, return 0 or DB_LOCK_DEADLOCK */ static int undump_from_fp(DB_TXN *tid, FILE *fp, const char *tag) { int err, c; - DBT k, d; - const char *which_name; - DB *which_db; info("undumping"); if(fseek(fp, 0, SEEK_SET) < 0) @@ -291,6 +236,28 @@ static int undump_from_fp(DB_TXN *tid, FILE *fp, const char *tag) { if((err = truncdb(tid, trackdb_scheduledb))) return err; c = getc(fp); while(!ferror(fp) && !feof(fp)) { + for(size_t n = 0; n < NDBTABLE; ++n) { + if(dbtable[n].letter == c) { + DB *db = *dbtable[n].db; + const char *dbname = dbtable[n].dbname; + DBT k, d; + + if(undump_dbt(fp, tag, prepare_data(&k)) + || undump_dbt(fp, tag, prepare_data(&d))) + break; + switch(err = db->put(db, tid, &k, &d, 0)) { + case 0: + break; + case DB_LOCK_DEADLOCK: + error(0, "error updating %s: %s", dbname, db_strerror(err)); + return err; + default: + fatal(0, "error updating %s: %s", dbname, db_strerror(err)); + } + goto next; + } + } + switch(c) { case 'V': c = getc(fp); @@ -299,54 +266,15 @@ static int undump_from_fp(DB_TXN *tid, FILE *fp, const char *tag) { break; case 'E': return 0; - case 'P': - case 'G': - case 'U': - case 'W': - switch(c) { - case 'P': - which_db = trackdb_prefsdb; - which_name = "prefs.db"; - break; - case 'G': - which_db = trackdb_globaldb; - which_name = "global.db"; - break; - case 'U': - which_db = trackdb_usersdb; - which_name = "users.db"; - break; - case 'W': /* for 'when' */ - which_db = trackdb_scheduledb; - which_name = "scheduledb.db"; - break; - default: - abort(); - } - if(undump_dbt(fp, tag, prepare_data(&k)) - || undump_dbt(fp, tag, prepare_data(&d))) - break; - switch(err = which_db->put(which_db, tid, &k, &d, 0)) { - case 0: - break; - case DB_LOCK_DEADLOCK: - error(0, "error updating %s: %s", which_name, db_strerror(err)); - return err; - default: - fatal(0, "error updating %s: %s", which_name, db_strerror(err)); - } - break; - case 'T': - case 'S': - if(undump_dbt(fp, tag, prepare_data(&k)) - || undump_dbt(fp, tag, prepare_data(&d))) - break; - /* We don't restore the tracks.db or search.db entries, instead - * we recompute them */ - break; case '\n': break; + default: + if(c >= 32 && c <= 126) + fatal(0, "unexpected character '%c'", c); + else + fatal(0, "unexpected character 0x%02X", c); } + next: c = getc(fp); } if(ferror(fp)) @@ -435,13 +363,13 @@ fail: int main(int argc, char **argv) { int n, dump = 0, undump = 0, recover = TRACKDB_NO_RECOVER, recompute = 0; - int tracksdb = 0, searchdb = 0, remove_pathless = 0, fd; + int remove_pathless = 0, fd; const char *path; char *tmp; FILE *fp; mem_init(); - while((n = getopt_long(argc, argv, "hVc:dDutsrRaP", options, 0)) >= 0) { + while((n = getopt_long(argc, argv, "hVc:dDurRaP", options, 0)) >= 0) { switch(n) { case 'h': help(); case 'V': version("disorder-dump"); @@ -449,8 +377,6 @@ int main(int argc, char **argv) { case 'd': dump = 1; break; case 'u': undump = 1; break; case 'D': debugging = 1; break; - case 't': tracksdb = 1; break; - case 's': searchdb = 1; break; case 'r': recover = TRACKDB_NORMAL_RECOVER; case 'R': recover = TRACKDB_FATAL_RECOVER; case 'a': recompute = 1; break; @@ -460,8 +386,6 @@ int main(int argc, char **argv) { } if(dump + undump + recompute != 1) fatal(0, "choose exactly one of --dump, --undump or --recompute-aliases"); - if((undump || recompute) && (tracksdb || searchdb)) - fatal(0, "--trackdb and --searchdb with --undump or --recompute-aliases"); if(recompute) { if(optind != argc) fatal(0, "--recompute-aliases does not take a filename"); @@ -484,7 +408,7 @@ int main(int argc, char **argv) { fatal(errno, "error opening %s", tmp); if(!(fp = fdopen(fd, "w"))) fatal(errno, "fdopen on %s", tmp); - do_dump(fp, tmp, tracksdb, searchdb); + do_dump(fp, tmp); if(fclose(fp) < 0) fatal(errno, "error closing %s", tmp); if(rename(tmp, path) < 0) fatal(errno, "error renaming %s to %s", tmp, path); diff --git a/server/server.c b/server/server.c index feaac7e..781c71a 100644 --- a/server/server.c +++ b/server/server.c @@ -44,6 +44,34 @@ struct listener { int pf; }; +struct conn; + +/** @brief Signature for line reader callback + * @param c Connection + * @param line Line + * @return 0 if incomplete, 1 if complete + * + * @p line is 0-terminated and excludes the newline. It points into the + * input buffer so will become invalid shortly. + */ +typedef int line_reader_type(struct conn *c, + char *line); + +/** @brief Signature for with-body command callbacks + * @param c Connection + * @param body List of body lines + * @param nbody Number of body lines + * @param u As passed to fetch_body() + * @return 0 to suspend input, 1 if complete + * + * The body strings are allocated (so survive indefinitely) and don't include + * newlines. + */ +typedef int body_callback_type(struct conn *c, + char **body, + int nbody, + void *u); + /** @brief One client connection */ struct conn { /** @brief Read commands from here */ @@ -77,6 +105,18 @@ struct conn { struct conn *next; /** @brief True if pending rescan had 'wait' set */ int rescan_wait; + /** @brief Playlist that this connection locks */ + const char *locked_playlist; + /** @brief When that playlist was locked */ + time_t locked_when; + /** @brief Line reader function */ + line_reader_type *line_reader; + /** @brief Called when command body has been read */ + body_callback_type *body_callback; + /** @brief Passed to @c body_callback */ + void *body_u; + /** @brief Accumulating body */ + struct vector body[1]; }; /** @brief Linked list of connections */ @@ -88,6 +128,15 @@ static int reader_callback(ev_source *ev, size_t bytes, int eof, void *u); +static int c_playlist_set_body(struct conn *c, + char **body, + int nbody, + void *u); +static int fetch_body(struct conn *c, + body_callback_type body_callback, + void *u); +static int body_line(struct conn *c, char *line); +static int command(struct conn *c, char *line); static const char *noyes[] = { "no", "yes" }; @@ -1030,21 +1079,25 @@ static int c_resolve(struct conn *c, return 1; } -static int c_tags(struct conn *c, - char attribute((unused)) **vec, - int attribute((unused)) nvec) { - char **tags = trackdb_alltags(); - - sink_printf(ev_writer_sink(c->w), "253 Tag list follows\n"); - while(*tags) { +static int list_response(struct conn *c, + const char *reply, + char **list) { + sink_printf(ev_writer_sink(c->w), "253 %s\n", reply); + while(*list) { sink_printf(ev_writer_sink(c->w), "%s%s\n", - **tags == '.' ? "." : "", *tags); - ++tags; + **list == '.' ? "." : "", *list); + ++list; } sink_writes(ev_writer_sink(c->w), ".\n"); return 1; /* completed */ } +static int c_tags(struct conn *c, + char attribute((unused)) **vec, + int attribute((unused)) nvec) { + return list_response(c, "Tag list follows", trackdb_alltags()); +} + static int c_set_global(struct conn *c, char **vec, int attribute((unused)) nvec) { @@ -1314,17 +1367,7 @@ static int c_userinfo(struct conn *c, static int c_users(struct conn *c, char attribute((unused)) **vec, int attribute((unused)) nvec) { - /* TODO de-dupe with c_tags */ - char **users = trackdb_listusers(); - - sink_writes(ev_writer_sink(c->w), "253 User list follows\n"); - while(*users) { - sink_printf(ev_writer_sink(c->w), "%s%s\n", - **users == '.' ? "." : "", *users); - ++users; - } - sink_writes(ev_writer_sink(c->w), ".\n"); - return 1; /* completed */ + return list_response(c, "User list follows", trackdb_listusers()); } static int c_register(struct conn *c, @@ -1599,6 +1642,152 @@ static int c_adopt(struct conn *c, return 1; } +static int playlist_response(struct conn *c, + int err) { + switch(err) { + case 0: + assert(!"cannot cope with success"); + case EACCES: + sink_writes(ev_writer_sink(c->w), "550 Access denied\n"); + break; + case EINVAL: + sink_writes(ev_writer_sink(c->w), "550 Invalid playlist name\n"); + break; + case ENOENT: + sink_writes(ev_writer_sink(c->w), "555 No such playlist\n"); + break; + default: + sink_writes(ev_writer_sink(c->w), "550 Error accessing playlist\n"); + break; + } + return 1; +} + +static int c_playlist_get(struct conn *c, + char **vec, + int attribute((unused)) nvec) { + char **tracks; + int err; + + if(!(err = trackdb_playlist_get(vec[0], c->who, &tracks, 0, 0))) + return list_response(c, "Playlist contents follows", tracks); + else + return playlist_response(c, err); +} + +static int c_playlist_set(struct conn *c, + char **vec, + int attribute((unused)) nvec) { + return fetch_body(c, c_playlist_set_body, vec[0]); +} + +static int c_playlist_set_body(struct conn *c, + char **body, + int nbody, + void *u) { + const char *playlist = u; + int err; + + if(!c->locked_playlist + || strcmp(playlist, c->locked_playlist)) { + sink_writes(ev_writer_sink(c->w), "550 Playlist is not locked\n"); + return 1; + } + if(!(err = trackdb_playlist_set(playlist, c->who, + body, nbody, 0))) { + sink_printf(ev_writer_sink(c->w), "250 OK\n"); + return 1; + } else + return playlist_response(c, err); +} + +static int c_playlist_get_share(struct conn *c, + char **vec, + int attribute((unused)) nvec) { + char *share; + int err; + + if(!(err = trackdb_playlist_get(vec[0], c->who, 0, 0, &share))) { + sink_printf(ev_writer_sink(c->w), "252 %s\n", quoteutf8(share)); + return 1; + } else + return playlist_response(c, err); +} + +static int c_playlist_set_share(struct conn *c, + char **vec, + int attribute((unused)) nvec) { + int err; + + if(!(err = trackdb_playlist_set(vec[0], c->who, 0, 0, vec[1]))) { + sink_printf(ev_writer_sink(c->w), "250 OK\n"); + return 1; + } else + return playlist_response(c, err); +} + +static int c_playlists(struct conn *c, + char attribute((unused)) **vec, + int attribute((unused)) nvec) { + char **p; + + trackdb_playlist_list(c->who, &p, 0); + return list_response(c, "List of playlists follows", p); +} + +static int c_playlist_delete(struct conn *c, + char **vec, + int attribute((unused)) nvec) { + int err; + + if(!(err = trackdb_playlist_delete(vec[0], c->who))) { + sink_writes(ev_writer_sink(c->w), "250 OK\n"); + return 1; + } else + return playlist_response(c, err); +} + +static int c_playlist_lock(struct conn *c, + char **vec, + int attribute((unused)) nvec) { + int err; + struct conn *cc; + + /* Check we're allowed to modify this playlist */ + if((err = trackdb_playlist_set(vec[0], c->who, 0, 0, 0))) + return playlist_response(c, err); + /* If we hold a lock don't allow a new one */ + if(c->locked_playlist) { + sink_writes(ev_writer_sink(c->w), "550 Already holding a lock\n"); + return 1; + } + /* See if some other connection locks the same playlist */ + for(cc = connections; cc; cc = cc->next) + if(cc->locked_playlist && !strcmp(cc->locked_playlist, vec[0])) + break; + if(cc) { + /* TODO: implement config->playlist_lock_timeout */ + sink_writes(ev_writer_sink(c->w), "550 Already locked\n"); + return 1; + } + c->locked_playlist = xstrdup(vec[0]); + time(&c->locked_when); + sink_writes(ev_writer_sink(c->w), "250 Acquired lock\n"); + return 1; +} + +static int c_playlist_unlock(struct conn *c, + char attribute((unused)) **vec, + int attribute((unused)) nvec) { + if(!c->locked_playlist) { + sink_writes(ev_writer_sink(c->w), "550 Not holding a lock\n"); + return 1; + } + c->locked_playlist = 0; + sink_writes(ev_writer_sink(c->w), "250 Released lock\n"); + return 1; +} + static const struct command { /** @brief Command name */ const char *name; @@ -1645,6 +1834,14 @@ static const struct command { { "pause", 0, 0, c_pause, RIGHT_PAUSE }, { "play", 1, 1, c_play, RIGHT_PLAY }, { "playing", 0, 0, c_playing, RIGHT_READ }, + { "playlist-delete", 1, 1, c_playlist_delete, RIGHT_PLAY }, + { "playlist-get", 1, 1, c_playlist_get, RIGHT_READ }, + { "playlist-get-share", 1, 1, c_playlist_get_share, RIGHT_READ }, + { "playlist-lock", 1, 1, c_playlist_lock, RIGHT_PLAY }, + { "playlist-set", 1, 1, c_playlist_set, RIGHT_PLAY }, + { "playlist-set-share", 2, 2, c_playlist_set_share, RIGHT_PLAY }, + { "playlist-unlock", 0, 0, c_playlist_unlock, RIGHT_PLAY }, + { "playlists", 0, 0, c_playlists, RIGHT_READ }, { "prefs", 1, 1, c_prefs, RIGHT_READ }, { "queue", 0, 0, c_queue, RIGHT_READ }, { "random-disable", 0, 0, c_random_disable, RIGHT_GLOBAL_PREFS }, @@ -1680,13 +1877,58 @@ static const struct command { { "volume", 0, 2, c_volume, RIGHT_READ|RIGHT_VOLUME } }; +/** @brief Fetch a command body + * @param c Connection + * @param body_callback Called with body + * @param u Passed to body_callback + * @return 1 + */ +static int fetch_body(struct conn *c, + body_callback_type body_callback, + void *u) { + assert(c->line_reader == command); + c->line_reader = body_line; + c->body_callback = body_callback; + c->body_u = u; + vector_init(c->body); + return 1; +} + +/** @brief @ref line_reader_type callback for command body lines + * @param c Connection + * @param line Line + * @return 1 if complete, 0 if incomplete + * + * Called from reader_callback(). + */ +static int body_line(struct conn *c, + char *line) { + if(*line == '.') { + ++line; + if(!*line) { + /* That's the lot */ + c->line_reader = command; + vector_terminate(c->body); + return c->body_callback(c, c->body->vec, c->body->nvec, c->body_u); + } + } + vector_append(c->body, xstrdup(line)); + return 1; /* completed */ +} + static void command_error(const char *msg, void *u) { struct conn *c = u; sink_printf(ev_writer_sink(c->w), "500 parse error: %s\n", msg); } -/* process a command. Return 1 if complete, 0 if incomplete. */ +/** @brief @ref line_reader_type callback for commands + * @param c Connection + * @param line Line + * @return 1 if complete, 0 if incomplete + * + * Called from reader_callback(). + */ static int command(struct conn *c, char *line) { char **vec; int nvec, n; @@ -1757,7 +1999,7 @@ static int reader_callback(ev_source attribute((unused)) *ev, while((eol = memchr(ptr, '\n', bytes))) { *eol++ = 0; ev_reader_consume(reader, eol - (char *)ptr); - complete = command(c, ptr); + complete = c->line_reader(c, ptr); /* usually command() */ bytes -= (eol - (char *)ptr); ptr = eol; if(!complete) { @@ -1820,6 +2062,7 @@ static int listen_callback(ev_source *ev, c->reader = reader_callback; c->l = l; c->rights = 0; + c->line_reader = command; connections = c; gcry_randomize(c->nonce, sizeof c->nonce, GCRY_STRONG_RANDOM); sink_printf(ev_writer_sink(c->w), "231 %d %s %s\n", diff --git a/tests/Makefile.am b/tests/Makefile.am index dfe7424..5563f7f 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -26,7 +26,7 @@ disorder_udplog_DEPENDENCIES=../lib/libdisorder.a TESTS=cookie.py dbversion.py dump.py files.py play.py queue.py \ recode.py search.py user-upgrade.py user.py aliases.py \ - schedule.py hashes.py + schedule.py hashes.py playlists.py TESTS_ENVIRONMENT=${PYTHON} -u diff --git a/tests/playlists.py b/tests/playlists.py new file mode 100755 index 0000000..0932632 --- /dev/null +++ b/tests/playlists.py @@ -0,0 +1,151 @@ +#! /usr/bin/env python +# +# 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 +# +import dtest,disorder + +def test(): + """Playlist testing""" + dtest.start_daemon() + dtest.create_user() + c = disorder.client() + c.random_disable() + # + print " checking initial playlist set is empty" + l = c.playlists() + assert l == [], "checking initial playlist set is empty" + # + print " creating a shared playlist" + c.playlist_lock("wibble") + c.playlist_set("wibble", ["one", "two", "three"]) + c.playlist_unlock() + print " checking new playlist appears in list" + l = c.playlists() + assert l == ["wibble"], "checking new playlists" + print " checking new playlist contents is as assigned" + l = c.playlist_get("wibble") + assert l == ["one", "two", "three"], "checking playlist contents" + # + print " checking new playlist is shared" + s = c.playlist_get_share("wibble") + assert s == "shared", "checking playlist is shared" + # + print " checking cannot unshare un-owned playlist" + try: + c.playlist_set_share("wibble", "private") + print "*** should not be able to adjust shared playlist's sharing ***" + assert False + except disorder.operationError: + pass # good + # + print " modifying shared playlist" + c.playlist_lock("wibble") + c.playlist_set("wibble", ["three", "two", "one"]) + c.playlist_unlock() + print " checking updated playlist contents is as assigned" + l = c.playlist_get("wibble") + assert l == ["three", "two", "one"], "checking modified playlist contents" + # + print " creating a private playlist" + c.playlist_lock("fred.spong") + c.playlist_set("fred.spong", ["a", "b", "c"]) + c.playlist_unlock() + s = c.playlist_get_share("fred.spong") + assert s == "private", "owned playlists are private by default" + # + print " creating a public playlist" + c.playlist_lock("fred.foo") + c.playlist_set("fred.foo", ["p", "q", "r"]) + c.playlist_set_share("fred.foo", "public") + c.playlist_unlock() + s = c.playlist_get_share("fred.foo") + assert s == "public", "new playlist is now public" + # + print " checking fred can see all playlists" + l = c.playlists() + assert dtest.lists_have_same_contents(l, + ["fred.spong", "fred.foo", "wibble"]), "playlist list is as expected" + # + print " adding a second user" + c.adduser("bob", "bobpass") + d = disorder.client(user="bob", password="bobpass") + print " checking bob cannot see fred's private playlist" + l = d.playlists() + assert dtest.lists_have_same_contents(l, + ["fred.foo", "wibble"]), "playlist list is as expected" + # + print " checking bob can read shared and public playlists" + d.playlist_get("wibble") + d.playlist_get("fred.foo") + print " checking bob cannot read private playlist" + try: + d.playlist_get("fred.spong") + print "*** should not be able to read private playlist ***" + assert False + except disorder.operationError: + pass # good + # + print " checking bob cannot modify fred's playlists" + try: + d.playlist_lock("fred.foo") + print "*** should not be able to lock fred's public playlist ***" + assert False + except disorder.operationError: + pass # good + try: + d.playlist_lock("fred.spong") + print "*** should not be able to lock fred's private playlist ***" + assert False + except disorder.operationError: + pass # good + print " checking unlocked playlists cannot be modified" + # + try: + c.playlist_set("wibble", ["a"]) + print "*** should not be able to modify unlocked playlists ***" + assert False + except disorder.operationError: + pass # good + # + print " deleting playlists" + c.playlist_delete("fred.spong") + l = c.playlists() + assert dtest.lists_have_same_contents(l, + ["fred.foo", "wibble"]) + try: + d.playlist_delete("fred.foo") + print "*** should not be to delete fred's playlist ***" + assert False + except disorder.operationError: + pass # good + d.playlist_delete("wibble") + l = c.playlists() + assert l == ["fred.foo"] + c.playlist_delete("fred.foo") + l = c.playlists() + assert l == [] + try: + c.playlist_delete("nonesuch") + print "*** should not be to delete nonexistent playlist ***" + assert False + except disorder.operationError: + pass # good + +if __name__ == '__main__': + dtest.run()