1
# -*- coding: utf-8 -*-
2
# Copyright 2004-2005 Joe Wreschnig, Michael Urman, IƱigo Serna
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License version 2 as
6
# published by the Free Software Foundation
8
# $Id: albums.py 4024 2007-04-27 02:00:16Z piman $
22
from browsers._base import Browser
23
from formats import PEOPLE
24
from parse import Query, XMLFromPattern
25
from qltk.ccb import ConfigCheckButton
26
from qltk.completion import EntryWordCompletion
27
from qltk.entry import ValidatingEntry
28
from qltk.songsmenu import SongsMenu
29
from qltk.textedit import PatternEditBox
30
from qltk.views import AllTreeView
31
from util import copool, tag
33
ELPOEP = list(PEOPLE); ELPOEP.reverse()
34
EMPTY = _("Songs not in an album")
35
PATTERN = r"""\<b\><title|\<i\><title>\</i\>|%s>\</b\><date| (<date>)>
36
\<small\><~discs|<~discs> - ><~tracks> - <~long-length>\</small\>
39
class AlbumTagCompletion(EntryWordCompletion):
41
super(AlbumTagCompletion, self).__init__()
42
try: model = self.__model
43
except AttributeError:
44
model = type(self).__model = gtk.ListStore(str)
47
self.set_text_column(0)
49
def __refreshmodel(self, *args):
50
for tag in ["title", "album", "date", "people", "artist", "genre"]:
51
self.__model.append(row=[tag])
52
for tag in ["tracks", "discs", "length", "date"]:
53
self.__model.append(row=["#(" + tag])
54
for tag in ["rating", "playcount", "skipcount"]:
55
for suffix in ["avg", "max", "min", "sum"]:
56
self.__model.append(row=["#(%s:%s" % (tag, suffix)])
58
class Preferences(qltk.Window):
60
super(Preferences, self).__init__()
61
self.set_border_width(12)
62
self.set_title(_("Album List Preferences") + " - Quod Libet")
63
self.add(gtk.VBox(spacing=6))
64
self.set_default_size(300, 200)
65
self.connect_object('delete-event', Preferences.__delete_event, self)
67
cb = ConfigCheckButton(
68
_("Show album _covers"), "browsers", "album_covers")
69
cb.set_active(config.getboolean("browsers", "album_covers"))
70
cb.connect('toggled', lambda s: AlbumList.toggle_covers())
71
self.child.pack_start(cb, expand=False)
73
vbox = gtk.VBox(spacing=6)
75
label.set_alignment(0.0, 0.5)
76
edit = PatternEditBox(PATTERN)
77
edit.text = AlbumList._Album._pattern_text
78
edit.apply.connect('clicked', self.__set_pattern, edit)
79
edit.buffer.connect_object(
80
'changed', self.__preview_pattern, edit, label)
81
vbox.pack_start(label, expand=False)
83
self.__preview_pattern(edit, label)
84
f = qltk.Frame(_("Album Display"), child=vbox)
85
self.child.pack_start(f)
89
def __delete_event(self, event):
93
def __set_pattern(self, apply, edit):
94
AlbumList.refresh_pattern(edit.text)
96
def __preview_pattern(self, edit, label):
97
album = AlbumList._Album(
98
util.tag("album"), util.tag("labelid"),
99
util.tag("musicbrainz_albumid"))
100
album.date = "2004-10-31"
104
album.people = [tag("artist"), tag("performer"), tag("arranger")]
105
album.genre = tag("genre")
106
try: text = XMLFromPattern(edit.text) % album
108
text = _("Invalid pattern")
109
edit.apply.set_sensitive(False)
110
try: pango.parse_markup(text, u"\u0000")
111
except gobject.GError:
112
text = _("Invalid pattern")
113
edit.apply.set_sensitive(False)
114
else: edit.apply.set_sensitive(True)
115
label.set_markup(text)
117
class AlbumList(Browser, gtk.VBox, util.InstanceTracker):
118
expand = qltk.RHPaned
119
__gsignals__ = Browser.__gsignals__
122
name = _("Album List")
123
accelerated_name = _("_Album List")
127
def init(klass, library):
128
pattern_fn = os.path.join(const.USERDIR, "album_pattern")
130
klass._Album._pattern_text = file(pattern_fn).read().rstrip()
131
except EnvironmentError: pass
133
klass._Album._pattern = XMLFromPattern(klass._Album._pattern_text)
135
klass._Album.cover = gtk.gdk.pixbuf_new_from_file_at_size(
136
stock.NO_ALBUM, 48, 48)
138
klass._Album.cover = None
141
def toggle_covers(klass):
142
on = config.getboolean("browsers", "album_covers")
143
for albumlist in klass.instances():
144
albumlist.__cover_column.set_visible(on)
147
def refresh_pattern(klass, pattern_text):
148
if pattern_text == klass._Album._pattern_text: return
149
klass._Album._pattern_text = pattern_text
150
klass._Album._pattern = XMLFromPattern(pattern_text)
151
for album in [row[0] for row in klass.__model if row[0] is not None]:
152
album.finalize(cover=False)
153
pattern_fn = os.path.join(const.USERDIR, "album_pattern")
154
f = file(pattern_fn, "w")
155
f.write(pattern_text + "\n")
159
def _init_model(klass, library):
160
klass.__model = model = klass._AlbumStore(object)
161
library.connect('removed', klass.__remove_songs, model)
162
library.connect('changed', klass.__changed_songs, model)
163
library.connect('added', klass.__add_songs, model)
164
klass.__add_songs(library, library.values(), model)
165
model.append(row=[None])
168
def __changed_songs(klass, library, changed, model):
169
to_update = klass.__remove_songs(library, changed, model, False)
170
to_update.update(klass.__add_songs(library, changed, model, False))
171
klass.__update(to_update, model)
174
def __update(klass, changed, model):
179
if album is not None and album.key in changed:
181
to_change.append((row.path, row.iter))
184
to_remove.append(row.iter)
185
album._model = album._iter = None
186
if to_change: map(model.row_changed, *zip(*to_change))
187
if to_remove: map(model.remove, to_remove)
190
def __remove_songs(klass, library, removed, model, update=True):
194
if album is not None and True in map(album.remove, removed):
195
changed.add(album.key)
196
if update: klass.__update(changed, model)
200
def __add_songs(klass, library, added, model, update=True):
201
albums = model.get_albums()
202
changed = set() # Keys of changed albums
203
new = [] # Added album instances
205
labelid = song.get("labelid", "")
206
mbid = song.get("musicbrainz_albumid", "")
208
if key not in albums:
209
new_album = klass._Album(song("album"), labelid, mbid)
210
albums[key] = new_album
211
new.append(new_album)
212
albums[key].songs.add(song)
215
model.append(row=[album])
216
if update: klass.__update(changed, model)
219
# Something like an AudioFile, but for a whole album.
220
class _Album(object):
221
__pending_covers = []
223
_pattern_text = PATTERN
224
_pattern = XMLFromPattern(PATTERN)
232
def __init__(self, title, labelid, mbid):
236
# The key uniquely identifies the album; this way, albums
237
# with different MBIDs or different label IDs but the same
238
# title are different, and albums with different MBIDs
239
# but the same label ID are the same (since MB uses separate
240
# MBIDs for each disc).
241
self.key = (title, labelid or mbid)
243
def get(self, key, default="", connector=u" - "):
245
return connector.join(map(self.get, util.tagsplit(key)))
246
elif key == "~#length": return self.length
247
elif key == "~#tracks": return self.tracks
248
elif key == "~#discs": return self.discs
249
elif key == "~length": return util.format_time(self.length)
250
elif key == "date": return self.date
251
elif key == "~long-length":
252
return util.format_time_long(self.length)
253
elif key in ["cover", "~cover"]: return (self.cover and "y") or ""
254
elif key in ["title", "album"]: return self.title
255
elif key == "people":
256
return "\n".join(self.people)
257
elif key.startswith("~#") and key[-4:-3] != ":": key += ":avg"
258
elif key == "~tracks":
260
"%d track", "%d tracks", self.tracks) % self.tracks
261
elif key == "~discs":
264
"%d disc", "%d discs", self.discs) % self.discs
267
if key.startswith("~#") and key[-4:-3] == ":":
268
# Using key:<func> runs the resulting list of values
269
# through the function before returning it.
272
func = {"max": max, "min": min, "sum": sum,
273
"avg": lambda s: float(sum(s)) / len(s)}.get(func)
274
if func: return func([song(key, 0) for song in self.songs])
276
# Otherwise, if the tag isn't one provided by the album
277
# object, look in songs for it.
279
for song in self.songs: values.update(song.list(key))
280
value = u"\n".join(list(values))
281
return value or default
284
def comma(self, *args):
285
return self.get(*args).replace("\n", ", ")
287
# All songs added, cache info.
288
def finalize(self, cover=True):
289
self.tracks = len(self.songs)
292
for song in self.songs:
293
# Rank people by "relevance" -- artists before composers
294
# before performers, then by number of appearances.
295
for w, key in enumerate(ELPOEP):
296
for person in song.list(key):
297
people[person] = people.get(person, 0) - 1000 ** w
299
self.discs = max(self.discs, song("~#disc", 0))
300
self.length += song.get("~#length", 0)
302
self.people = sorted(people.keys(), key=people.__getitem__)[:100]
307
else: self.date = song.comma("date")
309
self.markup = self._pattern % self
310
self._model[self._iter][0] = self
312
def remove(self, song):
313
try: self.songs.remove(song)
314
except KeyError: return False
317
# An auto-searching entry; it wraps is a TreeModelFilter whose parent
319
class FilterEntry(ValidatingEntry):
320
def __init__(self, model):
321
ValidatingEntry.__init__(self, Query.is_valid_color)
322
self.connect_object('changed', self.__filter_changed, model)
323
self.set_completion(AlbumTagCompletion())
324
self.__refill_id = None
327
model.set_visible_func(self.__parse)
329
def __parse(self, model, iter):
330
if self.__filter is None: return True
331
elif model[iter][0] is None: return True
332
else: return self.__filter(model[iter][0])
334
def __filter_changed(self, model):
335
if self.__refill_id is not None:
336
gobject.source_remove(self.__refill_id)
337
self.__refill_id = None
338
text = self.get_text().decode('utf-8')
339
if Query.is_parsable(text):
340
if not text: self.__filter = None
342
self.__filter = Query(
343
text, star=["people", "album"]).search
344
self.__refill_id = gobject.timeout_add(
345
500, self.__refilter, model)
347
def __refilter(self, model):
352
# Sorting, either by people or album title. It wraps a TreeModelSort
353
# whose parent is the album list.
354
class SortCombo(gtk.ComboBox):
355
def __init__(self, model):
356
# Contains string to display, function to do sorting
357
cbmodel = gtk.ListStore(str)
358
gtk.ComboBox.__init__(self, cbmodel)
359
cell = gtk.CellRendererText()
360
self.pack_start(cell, True)
361
self.add_attribute(cell, 'text', 0)
362
model.set_sort_func(100, self.__compare_title)
363
model.set_sort_func(101, self.__compare_artist)
364
model.set_sort_func(102, self.__compare_date)
367
_("Sort by title"), _("Sort by artist"), _("Sort by date")
368
]: cbmodel.append(row=[text])
370
self.connect_object('changed', self.__set_cmp_func, model)
371
try: active = config.getint('browsers', 'album_sort')
373
self.set_active(active)
375
def __set_cmp_func(self, model):
376
active = self.get_active()
377
config.set("browsers", "album_sort", str(active))
378
model.set_sort_column_id(100 + active, gtk.SORT_ASCENDING)
380
def __compare_title(self, model, i1, i2):
381
a1, a2 = model[i1][0], model[i2][0]
382
if (a1 and a2) is None: return cmp(a1, a2)
383
elif not a1.title: return 1
384
elif not a2.title: return -1
385
else: return cmp(a1.key, a2.key)
387
def __compare_artist(self, model, i1, i2):
388
a1, a2 = model[i1][0], model[i2][0]
389
if (a1 and a2) is None: return cmp(a1, a2)
390
elif not a1.title: return 1
391
elif not a2.title: return -1
392
elif not a1.people and a2.people: return 1
393
elif not a2.people and a1.people: return -1
394
else: return (cmp(a1.people and a1.people[0],
395
a2.people and a2.people[0]) or
396
cmp(a1.date or "ZZZZ", a2.date or "ZZZZ") or
399
def __compare_date(self, model, i1, i2):
400
a1, a2 = model[i1][0], model[i2][0]
401
if (a1 and a2) is None: return cmp(a1, a2)
402
elif not a1.title: return 1
403
elif not a2.title: return -1
404
elif not a1.date and a2.date: return 1
405
elif not a2.date and a1.date: return -1
406
else: return (cmp(a1.date, a2.date) or cmp(a1.key, a2.key))
408
class _AlbumStore(gtk.ListStore):
409
__gsignals__ = { "row-changed": "override" }
411
def __init__(self, *args, **kwargs):
412
super(AlbumList._AlbumStore, self).__init__(*args, **kwargs)
413
self.__pending_covers = []
415
def do_row_changed(self, path, iter):
416
album = self[iter][0]
421
if album.title and album.cover is type(album).cover:
422
if not self.__pending_covers:
423
copool.add(self.__scan_covers)
424
self.__pending_covers.append(album)
426
def __scan_covers(self):
427
while self.__pending_covers:
428
album = self.__pending_covers.pop()
429
if album._iter is None or album.cover is not type(album).cover:
431
song = list(album.songs)[0]
432
cover = song.find_cover()
433
if cover is not None:
435
cover = gtk.gdk.pixbuf_new_from_file_at_size(
437
except StandardError:
440
# add a black outline
441
w, h = cover.get_width(), cover.get_height()
442
newcover = gtk.gdk.Pixbuf(
443
gtk.gdk.COLORSPACE_RGB, True, 8, w + 2, h + 2)
444
newcover.fill(0x000000ff)
445
cover.copy_area(0, 0, w, h, newcover, 1, 1)
446
album.cover = newcover
447
self[album._iter][0] = album
450
def get_albums(self):
451
albums = [row[0] for row in self]
452
try: albums.remove(None)
453
except ValueError: pass
454
return dict([(a.key, a) for a in albums])
456
def __init__(self, library, player):
457
super(AlbumList, self).__init__(spacing=6)
458
self._register_instance()
459
if self.__model is None: AlbumList._init_model(library)
460
self.__save = bool(player)
462
sw = gtk.ScrolledWindow()
463
sw.set_shadow_type(gtk.SHADOW_IN)
465
view.set_headers_visible(False)
466
model_sort = gtk.TreeModelSort(self.__model)
467
model_filter = model_sort.filter_new()
469
render = gtk.CellRendererPixbuf()
470
self.__cover_column = column = gtk.TreeViewColumn("covers", render)
471
column.set_visible(config.getboolean("browsers", "album_covers"))
472
column.set_sizing(gtk.TREE_VIEW_COLUMN_GROW_ONLY)
473
render.set_property('xpad', 2)
474
render.set_property('ypad', 2)
475
render.set_property('width', 56)
476
render.set_property('height', 56)
478
def cell_data_pb(column, cell, model, iter):
479
album = model[iter][0]
480
if album is None: cell.set_property('pixbuf', None)
481
elif album.cover: cell.set_property('pixbuf', album.cover)
482
else: cell.set_property('pixbuf', None)
483
column.set_cell_data_func(render, cell_data_pb)
484
view.append_column(column)
486
render = gtk.CellRendererText()
487
column = gtk.TreeViewColumn("albums", render)
488
render.set_property('ellipsize', pango.ELLIPSIZE_END)
489
def cell_data(column, cell, model, iter):
490
album = model[iter][0]
492
text = "<b>%s</b>" % _("All Albums")
493
text += "\n" + ngettext("%d album", "%d albums",
494
len(model) - 1) % (len(model) - 1)
496
else: cell.markup = model[iter][0].markup
497
cell.set_property('markup', cell.markup)
498
column.set_cell_data_func(render, cell_data)
499
view.append_column(column)
501
view.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
502
view.set_rules_hint(True)
503
view.set_search_equal_func(self.__search_func)
504
view.set_search_column(0)
505
sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
507
e = self.FilterEntry(model_filter)
510
hb2.pack_start(qltk.ClearButton(e), expand=False)
512
if player: view.connect('row-activated', self.__play_selection, player)
513
view.get_selection().connect('changed', self.__selection_changed, e)
515
targets = [("text/x-quodlibet-songs", gtk.TARGET_SAME_APP, 1),
516
("text/uri-list", 0, 2)]
517
view.drag_source_set(
518
gtk.gdk.BUTTON1_MASK, targets, gtk.gdk.ACTION_COPY)
519
view.connect("drag-data-get", self.__drag_data_get)
520
view.connect_object('popup-menu', self.__popup, view, library)
522
hb = gtk.HBox(spacing=6)
523
hb.pack_start(self.SortCombo(model_sort), expand=False)
527
gtk.image_new_from_stock(gtk.STOCK_PREFERENCES, gtk.ICON_SIZE_MENU))
528
prefs.connect('clicked', self.__preferences)
529
hb.pack_start(prefs, expand=False)
530
self.pack_start(hb, expand=False)
531
self.pack_start(sw, expand=True)
532
view.set_model(model_filter)
535
def __search_func(self, model, column, key, iter):
536
try: value = model[iter][0].title
537
except AttributeError: return True
539
key = key.decode('utf-8')
540
return not (value.startswith(key) or value.lower().startswith(key))
542
def __popup(self, view, library):
543
songs = self.__get_selected_songs(view.get_selection())
544
menu = SongsMenu(library, songs)
546
button = gtk.ImageMenuItem(gtk.STOCK_REFRESH)
547
button.connect('activate', self.__refresh_album, view.get_selection())
548
menu.prepend(gtk.SeparatorMenuItem())
551
return view.popup_menu(menu, 0, gtk.get_current_event_time())
553
def __refresh_album(self, menuitem, selection):
554
model, rows = selection.get_selected_rows()
555
albums = [model[row][0] for row in rows]
557
albums = [model[row][0] for row in model]
559
album.cover = type(album).cover
562
def __get_selected_albums(self, selection):
563
model, rows = selection.get_selected_rows()
564
if not model or not rows: return set([])
565
albums = [model[row][0] for row in rows]
566
if None in albums: return None
569
def __get_selected_songs(self, selection):
570
model, rows = selection.get_selected_rows()
571
if not model or not rows: return []
572
albums = [model[row][0] for row in rows]
574
albums = [row[0] for row in model if row[0]]
575
# Sort first by how the albums appear in the model itself,
576
# then within the album using the default order.
579
songs.extend(sorted(album.songs))
582
def __drag_data_get(self, view, ctx, sel, tid, etime):
583
songs = self.__get_selected_songs(view.get_selection())
585
filenames = [song("~filename") for song in songs]
586
sel.set("text/x-quodlibet-songs", 8, "\x00".join(filenames))
587
else: sel.set_uris([song("~uri") for song in songs])
589
def __play_selection(self, view, indices, col, player):
592
def __preferences(self, button):
593
try: prefs = AlbumList.__prefs_win
594
except AttributeError: prefs = AlbumList.__prefs_win = Preferences()
595
win = qltk.get_top_parent(self)
596
top, left = win.get_position()
597
w, h = win.get_size()
598
dw, dh = prefs.get_size()
599
prefs.move((left + w // 2) - dw // 2, (top + h // 2) - dh // 2)
602
def filter(self, key, values):
603
assert(key == "album")
604
if not values: values = [""]
605
view = self.get_children()[1].child
606
selection = view.get_selection()
607
selection.unselect_all()
608
model = view.get_model()
611
if row[0] is not None and row[0].title in values:
612
selection.select_path(row.path)
614
view.set_cursor(row.path)
617
view.scroll_to_cell(first, use_align=True, row_align=0.5)
620
view = self.get_children()[1].child
621
selection = view.get_selection()
622
selection.unselect_all()
623
selection.select_path((0,))
626
self.get_children()[1].child.get_selection().emit('changed')
628
def can_filter(self, key):
629
return (key == "album")
632
assert (key == "album")
633
view = self.get_children()[1].child
634
model = view.get_model()
635
return [row[0].title for row in model if row[0]]
638
albums = config.get("browsers", "albums").split("\n")
639
view = self.get_children()[1].child
640
selection = view.get_selection()
641
# FIXME: If albums is "" then it could be either all albums or
642
# no albums. If it's "" and some other stuff, assume no albums,
643
# otherwise all albums.
644
selection.unselect_all()
645
if albums == [""]: selection.select_path((0,))
647
model = selection.get_tree_view().get_model()
650
if row[0] is not None and row[0].title in albums:
651
selection.select_path(row.path)
652
first = first or row.path
655
view.scroll_to_cell(first[0], use_align=True, row_align=0.5)
657
def scroll(self, song):
658
view = self.get_children()[1].child
659
model = view.get_model()
660
values = song.list("album")
661
album_key = song.album_key
663
if row[0] is not None and row[0].key == album_key:
664
view.scroll_to_cell(row.path[0], use_align=True, row_align=0.5)
667
def __selection_changed(self, selection, sort):
668
if sort.inhibit: return
669
songs = self.__get_selected_songs(selection)
670
albums = self.__get_selected_albums(selection)
672
self.emit('songs-selected', songs, None)
674
if albums is None: config.set("browsers", "albums", "")
676
confval = "\n".join([a.title for a in albums])
677
# Since ConfigParser strips a trailing \n...
678
if confval and confval[-1] == "\n":
679
confval = "\n" + confval[:-1]
680
config.set("browsers", "albums", confval)
682
browsers = [AlbumList]