1# Copyright 2006-2009 Scott Horowitz <stonecrest@gmail.com>
2# Copyright 2009-2014 Jonathan Ballet <jon@multani.info>
3#
4# This file is part of Sonata.
5#
6# Sonata is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# Sonata is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Sonata.  If not, see <http://www.gnu.org/licenses/>.
18
19import os
20import re
21import gettext
22import locale
23import threading # libsearchfilter_toggle starts thread libsearchfilter_loop
24import operator
25
26from gi.repository import Gtk, Gdk, GdkPixbuf, GObject, GLib, Pango
27
28from sonata import ui, misc, consts, formatting, breadcrumbs, mpdhelper as mpdh
29from sonata.artwork import get_multicd_album_root_dir
30from sonata.song import SongRecord
31
32
33VARIOUS_ARTISTS = _("Various Artists")
34
35
36def list_mark_various_artists_albums(albums):
37    for i in range(len(albums)):
38        if i + consts.NUM_ARTISTS_FOR_VA - 1 > len(albums)-1:
39            break
40        VA = False
41        for j in range(1, consts.NUM_ARTISTS_FOR_VA):
42            if albums[i].album.lower() != albums[i + j].album.lower() or \
43               albums[i].year  != albums[i + j].year or \
44               albums[i].path  != albums[i + j].path:
45                break
46            if albums[i].artist == albums[i + j].artist:
47                albums.pop(i + j)
48                break
49            if j == consts.NUM_ARTISTS_FOR_VA - 1:
50                VA = True
51        if VA:
52            album = albums[i]._asdict()
53            album['artist'] = VARIOUS_ARTISTS
54            albums[i] = SongRecord(**album)
55            j = 1
56            while i + j <= len(albums) - 1:
57                if albums[i].album.lower() == albums[i + j].album.lower() \
58                   and albums[i].year == albums[i + j].year:
59                    albums.pop(i + j)
60                else:
61                    break
62    return albums
63
64
65class Library:
66    def __init__(self, config, mpd, artwork, TAB_LIBRARY, settings_save,
67                 filter_key_pressed, on_add_item, connected,
68                 on_library_button_press, add_tab):
69        self.artwork = artwork
70        self.config = config
71        self.mpd = mpd
72        self.librarymenu = None # cyclic dependency, set later
73        self.settings_save = settings_save
74        self.filter_key_pressed = filter_key_pressed
75        self.on_add_item = on_add_item
76        self.connected = connected
77        self.on_library_button_press = on_library_button_press
78
79        self.NOTAG = _("Untagged")
80        self.search_terms = [_('Artist'), _('Title'), _('Album'), _('Genre'),
81                             _('Filename'), _('Everything')]
82        self.search_terms_mpd = ['artist', 'title', 'album', 'genre', 'file',
83                                 'any']
84
85        self.libfilterbox_cmd_buf = None
86        self.libfilterbox_cond = None
87        self.libfilterbox_source = None
88
89        self.prevlibtodo_base = None
90        self.prevlibtodo_base_results = None
91        self.prevlibtodo = None
92
93        self.save_timeout = None
94        self.libsearch_last_tooltip = None
95
96        self.lib_view_filesystem_cache = None
97        self.lib_view_artist_cache = None
98        self.lib_view_genre_cache = None
99        self.lib_view_album_cache = None
100        self.lib_list_genres = None
101        self.lib_list_artists = None
102        self.lib_list_albums = None
103        self.lib_list_years = None
104        self.view_caches_reset()
105
106        # Library tab
107        self.builder = ui.builder('library')
108        self.css_provider = ui.css_provider('library')
109
110        self.libraryvbox = self.builder.get_object('library_page_v_box')
111        self.library = self.builder.get_object('library_page_treeview')
112        self.library_selection = self.library.get_selection()
113        self.breadcrumbs = self.builder.get_object('library_crumbs_box')
114        self.crumb_section = self.builder.get_object(
115            'library_crumb_section_togglebutton')
116        self.crumb_section_image = self.builder.get_object(
117            'library_crumb_section_image')
118        self.crumb_break = self.builder.get_object(
119            'library_crumb_break_box')
120        self.breadcrumbs.set_crumb_break(self.crumb_break)
121        self.crumb_section_handler = None
122        expanderwindow2 = self.builder.get_object('library_page_scrolledwindow')
123        self.searchbox = self.builder.get_object('library_page_searchbox')
124        self.searchcombo = self.builder.get_object('library_page_searchbox_combo')
125        self.searchtext = self.builder.get_object('library_page_searchbox_entry')
126        self.searchbutton = self.builder.get_object('library_page_searchbox_button')
127        self.searchbutton.hide()
128        self.libraryview = self.builder.get_object('library_crumb_button')
129        self.tab_label_widget = self.builder.get_object('library_tab_eventbox')
130        tab_label = self.builder.get_object('library_tab_label')
131        tab_label.set_text(TAB_LIBRARY)
132
133        self.tab = add_tab(self.libraryvbox, self.tab_label_widget,
134                           TAB_LIBRARY, self.library)
135
136        # Assign some pixbufs for use in self.library
137        self.openpb2 = self.library.render_icon(Gtk.STOCK_OPEN,
138                                                Gtk.IconSize.LARGE_TOOLBAR)
139        self.harddiskpb2 = self.library.render_icon(Gtk.STOCK_HARDDISK,
140                                                   Gtk.IconSize.LARGE_TOOLBAR)
141        self.openpb = self.library.render_icon(Gtk.STOCK_OPEN,
142                                               Gtk.IconSize.MENU)
143        self.harddiskpb = self.library.render_icon(Gtk.STOCK_HARDDISK,
144                                                   Gtk.IconSize.MENU)
145        self.albumpb = self.library.render_icon('sonata-album',
146                                                Gtk.IconSize.LARGE_TOOLBAR)
147        self.genrepb = self.library.render_icon('gtk-orientation-portrait',
148                                                Gtk.IconSize.LARGE_TOOLBAR)
149        self.artistpb = self.library.render_icon('sonata-artist',
150                                                 Gtk.IconSize.LARGE_TOOLBAR)
151        self.sonatapb = self.library.render_icon('sonata',
152                                                 Gtk.IconSize.LARGE_TOOLBAR)
153
154        # list of the library views: (id, name, icon name, label)
155        self.VIEWS = [
156            (consts.VIEW_FILESYSTEM, 'filesystem',
157             Gtk.STOCK_HARDDISK, _("Filesystem")),
158            (consts.VIEW_ALBUM, 'album',
159             'sonata-album', _("Albums")),
160            (consts.VIEW_ARTIST, 'artist',
161             'sonata-artist', _("Artists")),
162            (consts.VIEW_GENRE, 'genre',
163             Gtk.STOCK_ORIENTATION_PORTRAIT, _("Genres")),
164            ]
165
166        self.library.connect('row_activated', self.on_library_row_activated)
167        self.library.connect('button_press_event',
168                             self.on_library_button_press)
169        self.library.connect('key-press-event', self.on_library_key_press)
170        self.library.connect('query-tooltip', self.on_library_query_tooltip)
171        expanderwindow2.connect('scroll-event', self.on_library_scrolled)
172        self.libraryview.connect('clicked', self.library_view_popup)
173        self.searchtext.connect('key-press-event',
174                                self.libsearchfilter_key_pressed)
175        self.searchtext.connect('activate', self.libsearchfilter_on_enter)
176        self.searchbutton.connect('clicked', self.on_search_end)
177
178        self.libfilter_changed_handler = self.searchtext.connect(
179            'changed', self.libsearchfilter_feed_loop)
180        searchcombo_changed_handler = self.searchcombo.connect(
181            'changed', self.on_library_search_combo_change)
182
183        # Initialize library data and widget
184        self.libraryposition = {}
185        self.libraryselectedpath = {}
186        self.searchcombo.handler_block(searchcombo_changed_handler)
187        self.searchcombo.set_active(self.config.last_search_num)
188        self.searchcombo.handler_unblock(searchcombo_changed_handler)
189        self.librarydata = Gtk.ListStore(GdkPixbuf.Pixbuf,
190                                         GObject.TYPE_PYOBJECT, str)
191        self.library.set_model(self.librarydata)
192        self.library.set_search_column(2)
193        self.librarycell = Gtk.CellRendererText()
194        self.librarycell.set_property("ellipsize", Pango.EllipsizeMode.END)
195        self.libraryimg = Gtk.CellRendererPixbuf()
196        self.librarycolumn = Gtk.TreeViewColumn()
197        self.librarycolumn.pack_start(self.libraryimg, False)
198        self.librarycolumn.pack_start(self.librarycell, True)
199        self.librarycolumn.add_attribute(self.libraryimg, 'pixbuf', 0)
200        self.librarycolumn.add_attribute(self.librarycell, 'markup', 2)
201        self.librarycolumn.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
202        self.library.append_column(self.librarycolumn)
203        self.library_selection.set_mode(Gtk.SelectionMode.MULTIPLE)
204
205    def get_libraryactions(self):
206        return [(name + 'view', icon, label,
207             None, None, self.on_libraryview_chosen)
208            for _view, name, icon, label in self.VIEWS]
209
210    def get_model(self):
211        return self.librarydata
212
213    def get_widgets(self):
214        return self.libraryvbox
215
216    def get_treeview(self):
217        return self.library
218
219    def get_selection(self):
220        return self.library_selection
221
222    def set_librarymenu(self, librarymenu):
223        self.librarymenu = librarymenu
224        self.librarymenu.attach_to_widget(self.libraryview, None)
225
226    def library_view_popup(self, button):
227        self.librarymenu.popup(None, None, self.library_view_position_menu,
228                               button, 1, 0)
229
230    def library_view_position_menu(self, _menu, button):
231        alloc = button.get_allocation()
232        return (self.config.x + alloc.x,
233                self.config.y + alloc.y + alloc.height,
234                True)
235
236    def on_libraryview_chosen(self, action):
237        if self.search_visible():
238            self.on_search_end(None)
239        if action.get_name() == 'filesystemview':
240            self.config.lib_view = consts.VIEW_FILESYSTEM
241        elif action.get_name() == 'artistview':
242            self.config.lib_view = consts.VIEW_ARTIST
243        elif action.get_name() == 'genreview':
244            self.config.lib_view = consts.VIEW_GENRE
245        elif action.get_name() == 'albumview':
246            self.config.lib_view = consts.VIEW_ALBUM
247        self.library.grab_focus()
248        self.libraryposition = {}
249        self.libraryselectedpath = {}
250        self.library_browse(root=SongRecord(path="/"))
251        try:
252            if len(self.librarydata) > 0:
253                first = Gtk.TreePath.new_first()
254                to = Gtk.TreePath.new()
255                to.append_index(len(self.librarydata) - 1)
256                self.library_selection.unselect_range(first, to)
257        except Exception as e:
258            # XXX import logger here in the future
259            raise e
260        GLib.idle_add(self.library.scroll_to_point, 0, 0)
261
262    def view_caches_reset(self):
263        # We should call this on first load and whenever mpd is
264        # updated.
265        self.lib_view_filesystem_cache = None
266        self.lib_view_artist_cache = None
267        self.lib_view_genre_cache = None
268        self.lib_view_album_cache = None
269        self.lib_list_genres = None
270        self.lib_list_artists = None
271        self.lib_list_albums = None
272        self.lib_list_years = None
273
274    def on_library_scrolled(self, _widget, _event):
275        try:
276            # Use GLib.idle_add so that we can get the visible
277            # state of the treeview
278            GLib.idle_add(self._on_library_scrolled)
279        except:
280            pass
281
282    def _on_library_scrolled(self):
283        if not self.config.show_covers:
284            return
285
286        # This avoids a warning about a NULL node in get_visible_range
287        if not self.library.props.visible:
288            return
289
290        visible_range = self.library.get_visible_range()
291
292        if visible_range is None:
293            return
294        else:
295            start_row, end_row = visible_range
296
297        self.artwork.library_artwork_update(self.librarydata, start_row,
298                                            end_row, self.albumpb)
299
300    def library_browse(self, _widget=None, root=None):
301        # Populates the library list with entries
302        if not self.connected():
303            return
304
305        if root is None or (self.config.lib_view == consts.VIEW_FILESYSTEM \
306                            and root.path is None):
307            root = SongRecord(path="/")
308        if self.config.wd is None or (self.config.lib_view == \
309                                      consts.VIEW_FILESYSTEM and \
310                                      self.config.wd.path is None):
311            self.config.wd = SongRecord(path="/")
312
313        prev_selection = []
314        prev_selection_root = False
315        prev_selection_parent = False
316        if root == self.config.wd:
317            # This will happen when the database is updated. So, lets save
318            # the current selection in order to try to re-select it after
319            # the update is over.
320            model, selected = self.library_selection.get_selected_rows()
321            for path in selected:
322                prev_selection.append(model.get_value(model.get_iter(path), 1))
323            self.libraryposition[self.config.wd] = \
324                    self.library.get_visible_rect().width
325            path_updated = True
326        else:
327            path_updated = False
328
329        new_level = self.library_get_data_level(root)
330        curr_level = self.library_get_data_level(self.config.wd)
331        # The logic below is more consistent with, e.g., thunar.
332        if new_level > curr_level:
333            # Save position and row for where we just were if we've
334            # navigated into a sub-directory:
335            self.libraryposition[self.config.wd] = \
336                    self.library.get_visible_rect().width
337            model, rows = self.library_selection.get_selected_rows()
338            if len(rows) > 0:
339                data = self.librarydata.get_value(
340                    self.librarydata.get_iter(rows[0]), 2)
341                self.libraryselectedpath[self.config.wd] = rows[0]
342        elif (self.config.lib_view == consts.VIEW_FILESYSTEM and \
343              root != self.config.wd) \
344        or (self.config.lib_view != consts.VIEW_FILESYSTEM and new_level != \
345            curr_level):
346            # If we've navigated to a parent directory, don't save
347            # anything so that the user will enter that subdirectory
348            # again at the top position with nothing selected
349            self.libraryposition[self.config.wd] = 0
350            self.libraryselectedpath[self.config.wd] = None
351
352        # In case sonata is killed or crashes, we'll save the library state
353        # in 5 seconds (first removing any current settings_save timeouts)
354        if self.config.wd != root:
355            try:
356                GLib.source_remove(self.save_timeout)
357            except:
358                pass
359            self.save_timeout = GLib.timeout_add(5000, self.settings_save)
360
361        self.config.wd = root
362        self.library.freeze_child_notify()
363        self.library.set_model(None)
364        self.librarydata.clear()
365
366        # Populate treeview with data:
367        bd = []
368        wd = self.config.wd
369        while len(bd) == 0:
370            if self.config.lib_view == consts.VIEW_FILESYSTEM:
371                bd = self.library_populate_filesystem_data(wd.path)
372            elif self.config.lib_view == consts.VIEW_ALBUM:
373                if wd.album is not None:
374                    bd = self.library_populate_data(artist=wd.artist,
375                                                    album=wd.album,
376                                                    year=wd.year)
377                else:
378                    bd = self.library_populate_toplevel_data(albumview=True)
379            elif self.config.lib_view == consts.VIEW_ARTIST:
380                if wd.artist is not None and wd.album is not None:
381                    bd = self.library_populate_data(artist=wd.artist,
382                                                    album=wd.album,
383                                                    year=wd.year)
384                elif self.config.wd.artist is not None:
385                    bd = self.library_populate_data(artist=wd.artist)
386                else:
387                    bd = self.library_populate_toplevel_data(artistview=True)
388            elif self.config.lib_view == consts.VIEW_GENRE:
389                if wd.genre is not None and \
390                   wd.artist is not None and \
391                   wd.album is not None:
392                    bd = self.library_populate_data(genre=wd.genre,
393                                                    artist=wd.artist,
394                                                    album=wd.album,
395                                                    year=wd.year)
396                elif wd.genre is not None:
397                    bd = self.library_populate_data(genre=wd.genre,
398                                                    artist=wd.artist)
399                else:
400                    bd = self.library_populate_toplevel_data(genreview=True)
401
402            if len(bd) == 0:
403                # Nothing found; go up a level until we reach the top level
404                # or results are found
405                last_wd = self.config.wd
406                self.config.wd = self.library_get_parent()
407                if self.config.wd == last_wd:
408                    break
409
410        for _sort, path in bd:
411            self.librarydata.append(path)
412
413        self.library.set_model(self.librarydata)
414        self.library.thaw_child_notify()
415
416        # Scroll back to set view for current dir:
417        self.library.realize()
418        GLib.idle_add(self.library_set_view, not path_updated)
419        if len(prev_selection) > 0 or prev_selection_root or \
420           prev_selection_parent:
421            # Retain pre-update selection:
422            self.library_retain_selection(prev_selection, prev_selection_root,
423                                          prev_selection_parent)
424
425        self.update_breadcrumbs()
426
427    def update_breadcrumbs(self):
428        # remove previous buttons
429        for b in self.breadcrumbs:
430            self.breadcrumbs.remove(b)
431
432        # find info for current view
433        view, _name, icon, label = [v for v in self.VIEWS
434                          if v[0] == self.config.lib_view][0]
435
436        # the first crumb is the root of the current view
437        self.crumb_section.set_label(label)
438        self.crumb_section_image.set_from_stock(icon, Gtk.IconSize.MENU)
439        self.crumb_section.set_tooltip_text(label)
440        if self.crumb_section_handler:
441            self.crumb_section.disconnect(self.crumb_section_handler)
442
443
444        crumbs = []
445        # crumbs are specific to the view
446        if view == consts.VIEW_FILESYSTEM:
447            if self.config.wd.path and self.config.wd.path != '/':
448                parts = self.config.wd.path.split('/')
449            else:
450                parts = [] # no crumbs for /
451            # append a crumb for each part
452            for i, part in enumerate(parts):
453                partpath = '/'.join(parts[:i + 1])
454                target = SongRecord(path=partpath)
455                crumbs.append((part, Gtk.STOCK_OPEN, None, target))
456        else:
457            parts = ()
458            if view == consts.VIEW_ALBUM:
459                # We don't want to show an artist button in album view
460                keys = 'genre', 'album'
461                nkeys = 2
462                parts = (self.config.wd.genre, self.config.wd.album)
463            else:
464                keys = 'genre', 'artist', 'album'
465                nkeys = 3
466                parts = (self.config.wd.genre, self.config.wd.artist,
467                         self.config.wd.album)
468            # append a crumb for each part
469            for i, key, part in zip(range(nkeys), keys, parts):
470                if part is None:
471                    continue
472                partdata = dict(list(zip(keys, parts))[:i + 1])
473                target = SongRecord(**partdata)
474                pb, icon = None, None
475                if key == 'album':
476                    # Album artwork, with self.alumbpb as a backup:
477                    cache_data = SongRecord(artist=self.config.wd.artist,
478                                            album=self.config.wd.album,
479                                            path=self.config.wd.path)
480                    pb = self.artwork.cache.get_pixbuf(cache_data,
481                                                       consts.LIB_COVER_SIZE)
482                    if pb is None:
483                        icon = 'album'
484                elif key == 'artist':
485                    icon = 'sonata-artist'
486                else:
487                    icon = Gtk.STOCK_ORIENTATION_PORTRAIT
488                crumbs.append((part, icon, pb, target))
489
490        if not len(crumbs):
491            self.crumb_section.set_active(True)
492            context = self.crumb_section.get_style_context()
493            context.add_class('last_crumb')
494        else:
495            self.crumb_section.set_active(False)
496            context = self.crumb_section.get_style_context()
497            context.remove_class('last_crumb')
498
499        self.crumb_section_handler = self.crumb_section.connect('toggled',
500            self.library_browse, SongRecord(path='/'))
501
502        # add a button for each crumb
503        for crumb in crumbs:
504            text, icon, pb, target = crumb
505            text = misc.escape_html(text)
506            label = Gtk.Label(text, use_markup=True)
507
508            if icon:
509                image = Gtk.Image.new_from_stock(icon, Gtk.IconSize.MENU)
510            elif pb:
511                pb = pb.scale_simple(16, 16, GdkPixbuf.InterpType.HYPER)
512                image = Gtk.Image.new_from_pixbuf(pb)
513
514            b = breadcrumbs.CrumbButton(image, label)
515
516            if crumb is crumbs[-1]:
517                # FIXME makes the button request minimal space:
518                b.set_active(True)
519                context = b.get_style_context()
520                context.add_class('last_crumb')
521
522            b.set_tooltip_text(label.get_label())
523            b.connect('toggled', self.library_browse, target)
524            self.breadcrumbs.pack_start(b, False, False, 0)
525            b.show_all()
526
527    def library_populate_filesystem_data(self, path):
528        # List all dirs/files at path
529        bd = []
530        if path == '/' and self.lib_view_filesystem_cache is not None:
531            # Use cache if possible...
532            bd = self.lib_view_filesystem_cache
533        else:
534            for item in self.mpd.lsinfo(path):
535                if 'directory' in item:
536                    name = os.path.basename(item['directory'])
537                    data = SongRecord(path=item["directory"])
538                    bd += [('d' + str(name).lower(), [self.openpb, data,
539                                                      misc.escape_html(name)])]
540                elif 'file' in item:
541                    data = SongRecord(path=item['file'])
542                    bd += [('f' + item['file'].lower(),
543                            [self.sonatapb, data,
544                             formatting.parse(self.config.libraryformat, item,
545                                              True)])]
546            bd.sort(key=operator.itemgetter(0))
547        return bd
548
549    def library_get_toplevel_cache(self, genreview=False, artistview=False,
550                                   albumview=False):
551        if genreview and self.lib_view_genre_cache is not None:
552            bd = self.lib_view_genre_cache
553        elif artistview and self.lib_view_artist_cache is not None:
554            bd = self.lib_view_artist_cache
555        elif albumview and self.lib_view_album_cache is not None:
556            bd = self.lib_view_album_cache
557        else:
558            return None
559        # Check if we can update any artwork:
560        for _sort, info in bd:
561            pb = info[0]
562            if pb == self.albumpb:
563                key = SongRecord(path=info[1].path, artist=info[1].artist,
564                                 album=info[1].album)
565                pb2 = self.artwork.cache.get_pixbuf(key,
566                                                    consts.LIB_COVER_SIZE)
567                if pb2 is not None:
568                    info[0] = pb2
569        return bd
570
571    def library_populate_toplevel_data(self, genreview=False, artistview=False,
572                                       albumview=False):
573        bd = self.library_get_toplevel_cache(genreview, artistview, albumview)
574        if bd is not None:
575            # We have our cached data, woot.
576            return bd
577        bd = []
578        if genreview or artistview:
579            # Only for artist/genre views, album view is handled differently
580            # since multiple artists can have the same album name
581            if genreview:
582                items = self.library_return_list_items('genre')
583                pb = self.genrepb
584            else:
585                items = self.library_return_list_items('artist')
586                pb = self.artistpb
587            if not (self.NOTAG in items):
588                items.append(self.NOTAG)
589            for item in items:
590                if genreview:
591                    playtime, num_songs = self.library_return_count(genre=item)
592                    data = SongRecord(genre=item)
593                else:
594                    playtime, num_songs = self.library_return_count(
595                        artist=item)
596                    data = SongRecord(artist=item)
597                if num_songs > 0:
598                    display = misc.escape_html(item)
599                    display += self.add_display_info(num_songs, playtime)
600                    bd += [(misc.lower_no_the(item), [pb, data, display])]
601        elif albumview:
602            albums = []
603            untagged_found = False
604            for item in self.mpd.listallinfo('/'):
605                if 'file' in item and 'album' in item:
606                    album = item['album']
607                    artist = item.get('artist', self.NOTAG)
608                    year = item.get('date', self.NOTAG)
609                    path = get_multicd_album_root_dir(
610                        os.path.dirname(item['file']))
611                    data = SongRecord(album=album, artist=artist,
612                                      year=year, path=path)
613                    albums.append(data)
614                    if album == self.NOTAG:
615                        untagged_found = True
616            if not untagged_found:
617                albums.append(SongRecord(album=self.NOTAG))
618            albums = misc.remove_list_duplicates(albums, case=False)
619            albums = list_mark_various_artists_albums(albums)
620            for item in albums:
621                album, artist, _genre, year, path = item
622                playtime, num_songs = self.library_return_count(artist=artist,
623                                                                album=album,
624                                                                year=year)
625                if num_songs > 0:
626                    data = SongRecord(artist=artist, album=album,
627                                           year=year, path=path)
628                    display = misc.escape_html(album)
629                    if artist and year and len(artist) > 0 and len(year) > 0 \
630                       and artist != self.NOTAG and year != self.NOTAG:
631                        display += " <span weight='light'>(%s, %s)</span>" \
632                                % (misc.escape_html(artist),
633                                   misc.escape_html(year))
634                    elif artist and len(artist) > 0 and artist != self.NOTAG:
635                        display += " <span weight='light'>(%s)</span>" \
636                                % misc.escape_html(artist)
637                    elif year and len(year) > 0 and year != self.NOTAG:
638                        display += " <span weight='light'>(%s)</span>" \
639                                % misc.escape_html(year)
640                    display += self.add_display_info(num_songs, playtime)
641                    bd += [(misc.lower_no_the(album), [self.albumpb, data,
642                                                       display])]
643        bd.sort(key=lambda key: locale.strxfrm(key[0]))
644        if genreview:
645            self.lib_view_genre_cache = bd
646        elif artistview:
647            self.lib_view_artist_cache = bd
648        elif albumview:
649            self.lib_view_album_cache = bd
650        return bd
651
652
653    def library_populate_data(self, genre=None, artist=None, album=None,
654                              year=None):
655        # Create treeview model info
656        bd = []
657        if genre is not None and artist is None and album is None:
658            # Artists within a genre
659            artists = self.library_return_list_items('artist', genre=genre)
660            if len(artists) > 0:
661                if not self.NOTAG in artists:
662                    artists.append(self.NOTAG)
663                for artist in artists:
664                    playtime, num_songs = self.library_return_count(
665                        genre=genre, artist=artist)
666                    if num_songs > 0:
667                        display = misc.escape_html(artist)
668                        display += self.add_display_info(num_songs, playtime)
669                        data = SongRecord(genre=genre, artist=artist)
670                        bd += [(misc.lower_no_the(artist),
671                                [self.artistpb, data, display])]
672        elif artist is not None and album is None:
673            # Albums/songs within an artist and possibly genre
674            # Albums first:
675            if genre is not None:
676                albums = self.library_return_list_items('album', genre=genre,
677                                                        artist=artist)
678            else:
679                albums = self.library_return_list_items('album', artist=artist)
680            for album in albums:
681                if genre is not None:
682                    years = self.library_return_list_items('date', genre=genre,
683                                                           artist=artist,
684                                                           album=album)
685                else:
686                    years = self.library_return_list_items('date',
687                                                           artist=artist,
688                                                           album=album)
689                if not self.NOTAG in years:
690                    years.append(self.NOTAG)
691                for year in years:
692                    if genre is not None:
693                        playtime, num_songs = self.library_return_count(
694                            genre=genre, artist=artist, album=album, year=year)
695                        if num_songs > 0:
696                            files = self.library_return_list_items(
697                                'file', genre=genre, artist=artist,
698                                album=album, year=year)
699                            path = os.path.dirname(files[0])
700                            data = SongRecord(genre=genre, artist=artist,
701                                              album=album, year=year, path=path)
702                    else:
703                        playtime, num_songs = self.library_return_count(
704                            artist=artist, album=album, year=year)
705                        if num_songs > 0:
706                            files = self.library_return_list_items(
707                                'file', artist=artist, album=album, year=year)
708                            path = os.path.dirname(files[0])
709                        cache_data = SongRecord(artist=artist, album=album,
710                                                path=path)
711                        data = SongRecord(artist=artist, album=album,
712                                          year=year, path=path)
713                    if num_songs > 0:
714                        cache_data = SongRecord(artist=artist, album=album, path=path)
715                        display = misc.escape_html(album)
716                        if year and len(year) > 0 and year != self.NOTAG:
717                            display += " <span weight='light'>(%s)</span>" \
718                                    % misc.escape_html(year)
719                        display += self.add_display_info(num_songs, playtime)
720                        ordered_year = year
721                        if ordered_year == self.NOTAG:
722                            ordered_year = '9999'
723                        pb = self.artwork.cache.get_pixbuf(
724                            cache_data, consts.LIB_COVER_SIZE,
725                            self.albumpb)
726                        bd += [(ordered_year + misc.lower_no_the(album),
727                                [pb, data, display])]
728            # Now, songs not in albums:
729            bd += self.library_populate_data_songs(genre, artist, self.NOTAG,
730                                                   None)
731        else:
732            # Songs within an album, artist, year, and possibly genre
733            bd += self.library_populate_data_songs(genre, artist, album, year)
734        bd.sort(key=lambda key: locale.strxfrm(key[0]))
735        return bd
736
737    def library_populate_data_songs(self, genre, artist, album, year):
738        bd = []
739        if genre is not None:
740            songs, _playtime, _num_songs = \
741            self.library_return_search_items(genre=genre, artist=artist,
742                                             album=album, year=year)
743        else:
744            songs, _playtime, _num_songs = self.library_return_search_items(
745                artist=artist, album=album, year=year)
746        for song in songs:
747            data = SongRecord(path=song.file)
748            track = str(song.get('track', 99)).zfill(2)
749            disc = str(song.get('disc', 99)).zfill(2)
750            try:
751                bd += [('f' + disc + track + misc.lower_no_the(song.title),
752                        [self.sonatapb, data, formatting.parse(
753                            self.config.libraryformat, song, True)])]
754            except:
755                bd += [('f' + disc + track + song.file.lower(),
756                        [self.sonatapb, data,
757                         formatting.parse(self.config.libraryformat, song,
758                                          True)])]
759        return bd
760
761    def library_return_list_items(self, itemtype, genre=None, artist=None,
762                                  album=None, year=None, ignore_case=True):
763        # Returns all items of tag 'itemtype', in alphabetical order,
764        # using mpd's 'list'. If searchtype is passed, use
765        # a case insensitive search, via additional 'list'
766        # queries, since using a single 'list' call will be
767        # case sensitive.
768        results = []
769        searches = self.library_compose_list_count_searchlist(genre, artist,
770                                                              album, year)
771        if len(searches) > 0:
772            for s in searches:
773                # If we have untagged tags (''), use search instead
774                # of list because list will not return anything.
775                if '' in s:
776                    items = []
777                    songs, playtime, num_songs = \
778                            self.library_return_search_items(genre, artist,
779                                                             album, year)
780                    for song in songs:
781                        items.append(song.get(itemtype))
782                else:
783                    items = self.mpd.list(itemtype, *s)
784                for item in items:
785                    if len(item) > 0:
786                        results.append(item)
787        else:
788            if genre is None and artist is None and album is None and year \
789               is None:
790                for item in self.mpd.list(itemtype):
791                    if len(item) > 0:
792                        results.append(item)
793        if ignore_case:
794            results = misc.remove_list_duplicates(results, case=False)
795        results.sort(key=locale.strxfrm)
796        return results
797
798    def library_return_count(self, genre=None, artist=None, album=None,
799                             year=None):
800        # Because mpd's 'count' is case sensitive, we have to
801        # determine all equivalent items (case insensitive) and
802        # call 'count' for each of them. Using 'list' + 'count'
803        # involves much less data to be transferred back and
804        # forth than to use 'search' and count manually.
805        searches = self.library_compose_list_count_searchlist(genre, artist,
806                                                              album, year)
807        playtime = 0
808        num_songs = 0
809        for s in searches:
810            count = self.mpd.count(*s)
811            playtime += count.playtime
812            num_songs += count.songs
813
814        return (playtime, num_songs)
815
816    def library_compose_list_count_searchlist_single(self, search, typename,
817                                                     cached_list, searchlist):
818        s = []
819        skip_type = (typename == 'artist' and search == VARIOUS_ARTISTS)
820        if search is not None and not skip_type:
821            if search == self.NOTAG:
822                itemlist = [search, '']
823            else:
824                itemlist = []
825                if cached_list is None:
826                    cached_list = self.library_return_list_items(typename,
827                                                             ignore_case=False)
828                    # This allows us to match untagged items
829                    cached_list.append('')
830                for item in cached_list:
831                    if str(item).lower() == str(search).lower():
832                        itemlist.append(item)
833            if len(itemlist) == 0:
834                # There should be no results!
835                return None, cached_list
836            for item in itemlist:
837                if len(searchlist) > 0:
838                    for item2 in searchlist:
839                        s.append(item2 + (typename, item))
840                else:
841                    s.append((typename, item))
842        else:
843            s = searchlist
844        return s, cached_list
845
846    def library_compose_list_count_searchlist(self, genre=None, artist=None,
847                                              album=None, year=None):
848        s = []
849        s, self.lib_list_genres = \
850                self.library_compose_list_count_searchlist_single(
851                    genre, 'genre', self.lib_list_genres, s)
852        if s is None:
853            return []
854        s, self.lib_list_artists = \
855                self.library_compose_list_count_searchlist_single(
856                    artist, 'artist', self.lib_list_artists, s)
857        if s is None:
858            return []
859        s, self.lib_list_albums = \
860                self.library_compose_list_count_searchlist_single(
861                    album, 'album', self.lib_list_albums, s)
862        if s is None:
863            return []
864        s, self.lib_list_years = \
865                self.library_compose_list_count_searchlist_single(
866                    year, 'date', self.lib_list_years, s)
867        if s is None:
868            return []
869        return s
870
871    def library_compose_search_searchlist_single(self, search, typename,
872                                                 searchlist):
873        s = []
874        skip_type = (typename == 'artist' and search == VARIOUS_ARTISTS)
875        if search is not None and not skip_type:
876            if search == self.NOTAG:
877                itemlist = [search, '']
878            else:
879                itemlist = [search]
880            for item in itemlist:
881                if len(searchlist) > 0:
882                    for item2 in searchlist:
883                        s.append(item2 + (typename, item))
884                else:
885                    s.append((typename, item))
886        else:
887            s = searchlist
888        return s
889
890    def library_compose_search_searchlist(self, genre=None, artist=None,
891                                          album=None, year=None):
892        s = []
893        s = self.library_compose_search_searchlist_single(genre, 'genre', s)
894        s = self.library_compose_search_searchlist_single(album, 'album', s)
895        s = self.library_compose_search_searchlist_single(artist, 'artist', s)
896        s = self.library_compose_search_searchlist_single(year, 'date', s)
897        return s
898
899    def library_return_search_items(self, genre=None, artist=None, album=None,
900                                    year=None):
901        # Returns all mpd items, using mpd's 'search', along with
902        # playtime and num_songs.
903        searches = self.library_compose_search_searchlist(genre, artist, album,
904                                                          year)
905        for s in searches:
906            args_tuple = tuple(map(str, s))
907            playtime = 0
908            num_songs = 0
909            results = []
910            strip_type = None
911
912            if len(args_tuple) == 0:
913                return None, 0, 0
914
915            items = self.mpd.search(*args_tuple)
916            if items is not None:
917                for item in items:
918                    if strip_type is None or (strip_type is not None and not \
919                                              strip_type in item.keys()):
920                        match = True
921                        pos = 0
922                        # Ensure that if, e.g., "foo" is searched,
923                        # "foobar" isn't returned too
924                        for arg in args_tuple[::2]:
925                            if arg in item and \
926                               str(item.get(arg, '')).upper() != \
927                               str(args_tuple[pos + 1]).upper():
928                                match = False
929                                break
930                            pos += 2
931                        if match:
932                            results.append(item)
933                            num_songs += 1
934                            playtime += item.time
935        return (results, int(playtime), num_songs)
936
937    def add_display_info(self, num_songs, playtime):
938        seconds = int(playtime)
939        hours   = seconds // 3600
940        seconds -= 3600 * hours
941        minutes = seconds // 60
942        seconds -= 60 * minutes
943        songs_text = ngettext('{count} song', '{count} songs',
944                              num_songs).format(count=num_songs)
945        seconds_text = ngettext('{count} second', '{count} seconds',
946                                seconds).format(count=seconds)
947        minutes_text = ngettext('{count} minute', '{count} minutes',
948                                minutes).format(count=minutes)
949        hours_text = ngettext('{count} hour', '{count} hours',
950                              hours).format(count=hours)
951        time_parts = [songs_text]
952        if hours > 0:
953            time_parts.extend([hours_text, minutes_text])
954        elif minutes > 0:
955            time_parts.extend([minutes_text, seconds_text])
956        else:
957            time_parts.extend([seconds_text])
958        display_markup = "\n<small><span weight='light'>{}</span></small>"
959        display_text = ', '.join(time_parts)
960        return display_markup.format(display_text)
961
962    def library_retain_selection(self, prev_selection, prev_selection_root,
963                                 prev_selection_parent):
964        # Unselect everything:
965        if len(self.librarydata) > 0:
966            first = Gtk.TreePath.new_first()
967            to = Gtk.TreePath.new()
968            to.append_index(len(self.librarydata) - 1)
969            self.library_selection.unselect_range(first, to)
970        # Now attempt to retain the selection from before the update:
971        for value in prev_selection:
972            for row in self.librarydata:
973                if value == row[1]:
974                    self.library_selection.select_path(row.path)
975                    break
976        if prev_selection_root:
977            self.library_selection.select_path((0,))
978        if prev_selection_parent:
979            self.library_selection.select_path((1,))
980
981    def library_set_view(self, select_items=True):
982        # select_items should be false if the same directory has merely
983        # been refreshed (updated)
984        try:
985            if self.config.wd in self.libraryposition:
986                self.library.scroll_to_point(
987                    -1, self.libraryposition[self.config.wd])
988            else:
989                self.library.scroll_to_point(0, 0)
990        except:
991            self.library.scroll_to_point(0, 0)
992
993        # Select and focus previously selected item
994        if select_items:
995            if self.config.wd in self.libraryselectedpath:
996                try:
997                    if self.libraryselectedpath[self.config.wd]:
998                        self.library_selection.select_path(
999                            self.libraryselectedpath[self.config.wd])
1000                        self.library.grab_focus()
1001                except:
1002                    pass
1003
1004    def library_get_data_level(self, data):
1005        if self.config.lib_view == consts.VIEW_FILESYSTEM:
1006            # Returns the number of directories down:
1007            if data.path == '/':
1008                # Every other path doesn't start with "/", so
1009                # start the level numbering at -1
1010                return -1
1011            else:
1012                return data.path.count("/")
1013        else:
1014            # Returns the number of items stored in data, excluding
1015            # the path:
1016            level = 0
1017            for item in data:
1018                if item is not None:
1019                    level += 1
1020            return level
1021
1022    def on_library_key_press(self, widget, event):
1023        if event.keyval == Gdk.keyval_from_name('Return'):
1024            self.on_library_row_activated(widget, widget.get_cursor()[0])
1025            return True
1026
1027    def on_library_query_tooltip(self, widget, x, y, keyboard_mode, tooltip):
1028        if keyboard_mode or not self.search_visible():
1029            widget.set_tooltip_text("")
1030            return False
1031
1032        bin_x, bin_y = widget.convert_widget_to_bin_window_coords(x, y)
1033
1034        pathinfo = widget.get_path_at_pos(bin_x, bin_y)
1035        if not pathinfo:
1036            widget.set_tooltip_text("")
1037            # If the user hovers over an empty row and then back to
1038            # a row with a search result, this will ensure the tooltip
1039            # shows up again:
1040            GLib.idle_add(self.library_search_tooltips_enable, widget, x, y,
1041                          keyboard_mode, None)
1042            return False
1043        treepath, _col, _x2, _y2 = pathinfo
1044
1045        i = self.librarydata.get_iter(treepath.get_indices()[0])
1046        path = misc.escape_html(self.librarydata.get_value(i, 1).path)
1047        song = self.librarydata.get_value(i, 2)
1048        new_tooltip = "<b>%s:</b> %s\n<b>%s:</b> %s" \
1049                % (_("Song"), song, _("Path"), path)
1050
1051        if new_tooltip != self.libsearch_last_tooltip:
1052            self.libsearch_last_tooltip = new_tooltip
1053            self.library.set_property('has-tooltip', False)
1054            GLib.idle_add(self.library_search_tooltips_enable, widget, x, y,
1055                          keyboard_mode, tooltip)
1056            GLib.idle_add(widget.set_tooltip_markup, new_tooltip)
1057            return
1058
1059        self.libsearch_last_tooltip = new_tooltip
1060
1061        return False #api says we should return True, but this doesn't work?
1062
1063    def library_search_tooltips_enable(self, widget, x, y, keyboard_mode,
1064                                       tooltip):
1065        self.library.set_property('has-tooltip', True)
1066        if tooltip is not None:
1067            self.on_library_query_tooltip(widget, x, y, keyboard_mode, tooltip)
1068
1069    def on_library_row_activated(self, _widget, path, _column=0):
1070        if path is None:
1071            # Default to last item in selection:
1072            _model, selected = self.library_selection.get_selected_rows()
1073            if len(selected) >= 1:
1074                path = selected[0]
1075            else:
1076                return
1077        value = self.librarydata.get_value(self.librarydata.get_iter(path), 1)
1078        icon = self.librarydata.get_value(self.librarydata.get_iter(path), 0)
1079        if icon == self.sonatapb:
1080            # Song found, add item
1081            self.on_add_item(self.library)
1082        elif value.path == "..":
1083            self.library_browse_parent(None)
1084        else:
1085            self.library_browse(None, value)
1086
1087    def library_get_parent(self):
1088        wd = self.config.wd
1089        if self.config.lib_view == consts.VIEW_ALBUM:
1090            value = SongRecord(path="/")
1091        elif self.config.lib_view == consts.VIEW_ARTIST:
1092            if wd.album is None:
1093                value = SongRecord(path="/")
1094            else:
1095                value = SongRecord(artist = wd.artist)
1096        elif self.config.lib_view == consts.VIEW_GENRE:
1097            if wd.album is not None:
1098                value = SongRecord(genre=wd.genre,
1099                                   artist=wd.artist)
1100            elif wd.artist is not None:
1101                value = SongRecord(genre=wd.genre)
1102            else:
1103                value = SongRecord(path="/")
1104        else:
1105            newvalue = '/'.join(wd.path.split('/')[:-1]) or '/'
1106            value = SongRecord(path=newvalue)
1107        return value
1108
1109    def library_browse_parent(self, _action):
1110        if not self.search_visible():
1111            if self.library.is_focus():
1112                value = self.library_get_parent()
1113                self.library_browse(None, value)
1114                return True
1115
1116    def not_parent_is_selected(self):
1117        # Returns True if something is selected and it's not
1118        # ".." or "/":
1119        model, rows = self.library_selection.get_selected_rows()
1120        for path in rows:
1121            i = model.get_iter(path)
1122            value = model.get_value(i, 2)
1123            if value != ".." and value != "/":
1124                return True
1125        return False
1126
1127    def get_path_child_filenames(self, return_root, selected_only=True):
1128        # If return_root=True, return main directories whenever possible
1129        # instead of individual songs in order to reduce the number of
1130        # mpd calls we need to make. We won't want this behavior in some
1131        # instances, like when we want all end files for editing tags
1132        items = []
1133        if selected_only:
1134            model, rows = self.library_selection.get_selected_rows()
1135        else:
1136            model = self.librarydata
1137            rows = [(i,) for i in range(len(model))]
1138        for path in rows:
1139            i = model.get_iter(path)
1140            pb = model.get_value(i, 0)
1141            data = model.get_value(i, 1)
1142            value = model.get_value(i, 2)
1143            if value != ".." and value != "/":
1144                if data.path is not None and data.album is None and data.artist is None and \
1145                   data.year is None and data.genre is None:
1146                    if pb == self.sonatapb:
1147                        # File
1148                        items.append(data.path)
1149                    else:
1150                        # Directory
1151                        if not return_root:
1152                            items += self.library_get_path_files_recursive(
1153                                data.path)
1154                        else:
1155                            items.append(data.path)
1156                else:
1157                    results, _playtime, _num_songs = \
1158                            self.library_return_search_items(
1159                                genre=data.genre, artist=data.artist, album=data.album,
1160                                year=data.year)
1161                    for item in results:
1162                        items.append(item.file)
1163        # Make sure we don't have any EXACT duplicates:
1164        items = misc.remove_list_duplicates(items, case=True)
1165        return items
1166
1167    def library_get_path_files_recursive(self, path):
1168        results = []
1169        for item in self.mpd.lsinfo(path):
1170            if 'directory' in item:
1171                results = results + self.library_get_path_files_recursive(
1172                    item['directory'])
1173            elif 'file' in item:
1174                results.append(item['file'])
1175        return results
1176
1177    def on_library_search_combo_change(self, _combo=None):
1178        self.config.last_search_num = self.searchcombo.get_active()
1179        if not self.search_visible():
1180            return
1181        self.prevlibtodo = ""
1182        self.prevlibtodo_base = "__"
1183        self.libsearchfilter_feed_loop(self.searchtext)
1184
1185    def on_search_end(self, _button, move_focus=True):
1186        if self.search_visible():
1187            self.libsearchfilter_toggle(move_focus)
1188
1189    def search_visible(self):
1190        return self.searchbutton.get_property('visible')
1191
1192    def libsearchfilter_toggle(self, move_focus):
1193        if not self.search_visible() and self.connected():
1194            self.library.set_property('has-tooltip', True)
1195            ui.show(self.searchbutton)
1196            self.prevlibtodo = 'foo'
1197            self.prevlibtodo_base = "__"
1198            self.prevlibtodo_base_results = []
1199            # extra thread for background search work,
1200            # synchronized with a condition and its internal mutex
1201            self.libfilterbox_cond = threading.Condition()
1202            self.libfilterbox_cmd_buf = self.searchtext.get_text()
1203            qsearch_thread = threading.Thread(target=self.libsearchfilter_loop)
1204            qsearch_thread.name = "LibraryFilter"
1205            qsearch_thread.daemon = True
1206            qsearch_thread.start()
1207        elif self.search_visible():
1208            ui.hide(self.searchbutton)
1209            self.searchtext.handler_block(self.libfilter_changed_handler)
1210            self.searchtext.set_text("")
1211            self.searchtext.handler_unblock(self.libfilter_changed_handler)
1212            self.libsearchfilter_stop_loop()
1213            # call library_browse from the main thread to avoid corruption
1214            # of treeview, fixes #1959
1215            GLib.idle_add(self.library_browse, None, self.config.wd)
1216            if move_focus:
1217                self.library.grab_focus()
1218
1219    def libsearchfilter_feed_loop(self, editable):
1220        if not self.search_visible():
1221            self.libsearchfilter_toggle(None)
1222        # Lets only trigger the searchfilter_loop if 200ms pass
1223        # without a change in Gtk.Entry
1224        try:
1225            GLib.source_remove(self.libfilterbox_source)
1226        except:
1227            pass
1228        self.libfilterbox_source = GLib.timeout_add(
1229            300, self.libsearchfilter_start_loop, editable)
1230
1231    def libsearchfilter_start_loop(self, editable):
1232        self.libfilterbox_cond.acquire()
1233        self.libfilterbox_cmd_buf = editable.get_text()
1234        self.libfilterbox_cond.notifyAll()
1235        self.libfilterbox_cond.release()
1236
1237    def libsearchfilter_stop_loop(self):
1238        self.libfilterbox_cond.acquire()
1239        self.libfilterbox_cmd_buf = '$$$QUIT###'
1240        self.libfilterbox_cond.notifyAll()
1241        self.libfilterbox_cond.release()
1242
1243    def libsearchfilter_loop(self):
1244        while True:
1245            # copy the last command or pattern safely
1246            self.libfilterbox_cond.acquire()
1247            try:
1248                while(self.libfilterbox_cmd_buf == '$$$DONE###'):
1249                    self.libfilterbox_cond.wait()
1250                todo = self.libfilterbox_cmd_buf
1251                self.libfilterbox_cond.release()
1252            except:
1253                todo = self.libfilterbox_cmd_buf
1254            searchby = self.search_terms_mpd[self.config.last_search_num]
1255            if self.prevlibtodo != todo:
1256                if todo == '$$$QUIT###':
1257                    GLib.idle_add(ui.reset_entry_marking, self.searchtext)
1258                    return
1259                elif len(todo) > 1:
1260                    GLib.idle_add(self.libsearchfilter_do_search, searchby,
1261                                  todo)
1262                elif len(todo) == 0:
1263                    GLib.idle_add(ui.reset_entry_marking, self.searchtext)
1264                    self.libsearchfilter_toggle(False)
1265                else:
1266                    GLib.idle_add(ui.reset_entry_marking, self.searchtext)
1267            self.libfilterbox_cond.acquire()
1268            self.libfilterbox_cmd_buf = '$$$DONE###'
1269            try:
1270                self.libfilterbox_cond.release()
1271            except Exception as e:
1272                # XXX add logger here in the future!
1273                raise e
1274            self.prevlibtodo = todo
1275
1276    def libsearchfilter_do_search(self, searchby, todo):
1277        if not self.prevlibtodo_base in todo:
1278            # Do library search based on first two letters:
1279            self.prevlibtodo_base = todo[:2]
1280            self.prevlibtodo_base_results = self.mpd.search(searchby,
1281                                                             self.prevlibtodo_base)
1282            subsearch = False
1283        else:
1284            subsearch = True
1285
1286        # Now, use filtering similar to playlist filtering:
1287        # this make take some seconds... and we'll escape the search text
1288        # because we'll be searching for a match in items that are also escaped
1289        #
1290        # Note that the searching is not order specific. That is, "foo bar"
1291        # will match on "fools bar" and "barstool foo".
1292
1293        todos = todo.split(" ")
1294        regexps = []
1295        for i in range(len(todos)):
1296            todos[i] = misc.escape_html(todos[i])
1297            todos[i] = re.escape(todos[i])
1298            todos[i] = '.*' + todos[i].lower()
1299            regexps.append(re.compile(todos[i]))
1300        matches = []
1301        if searchby != 'any':
1302            for row in self.prevlibtodo_base_results:
1303                is_match = True
1304                for regexp in regexps:
1305                    if not regexp.match(row.get(searchby, '').lower()):
1306                        is_match = False
1307                        break
1308                if is_match:
1309                    matches.append(row)
1310        else:
1311            for row in self.prevlibtodo_base_results:
1312                allstr = " ".join(row.values())
1313                is_match = True
1314                for regexp in regexps:
1315                    if not regexp.match(str(allstr).lower()):
1316                        is_match = False
1317                        break
1318                if is_match:
1319                    matches.append(row)
1320        if subsearch and len(matches) == len(self.librarydata):
1321            # nothing changed..
1322            return
1323        self.library.freeze_child_notify()
1324        currlen = len(self.librarydata)
1325        bd = [(self.sonatapb,
1326               SongRecord(path=item['file']),
1327               formatting.parse(self.config.libraryformat, item, True))
1328              for item in matches if 'file' in item]
1329        bd.sort(key=lambda key: locale.strxfrm(key[2]))
1330        for i, item in enumerate(bd):
1331            if i < currlen:
1332                j = self.librarydata.get_iter((i, ))
1333                for index in range(len(item)):
1334                    if item[index] != self.librarydata.get_value(j, index):
1335                        self.librarydata.set_value(j, index, item[index])
1336            else:
1337                self.librarydata.append(item)
1338        # Remove excess items...
1339        newlen = len(bd)
1340        if newlen == 0:
1341            self.librarydata.clear()
1342        else:
1343            for i in range(currlen - newlen):
1344                j = self.librarydata.get_iter((currlen - 1 - i,))
1345                self.librarydata.remove(j)
1346        self.library.thaw_child_notify()
1347        if len(matches) == 0:
1348            GLib.idle_add(ui.set_entry_invalid, self.searchtext)
1349        else:
1350            GLib.idle_add(self.library.set_cursor, Gtk.TreePath.new_first(),
1351                          None, False)
1352            GLib.idle_add(ui.reset_entry_marking, self.searchtext)
1353
1354    def libsearchfilter_key_pressed(self, widget, event):
1355        self.filter_key_pressed(widget, event, self.library)
1356
1357    def libsearchfilter_on_enter(self, _entry):
1358        self.on_library_row_activated(None, None)
1359
1360    def libsearchfilter_set_focus(self):
1361        GLib.idle_add(self.searchtext.grab_focus)
1362
1363    def libsearchfilter_get_style(self):
1364        return self.searchtext.get_style()
1365