1
# -*- coding: utf-8 -*-
2
# Copyright 2005 Joe Wreschnig
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: playlists.py 3921 2006-10-21 03:54:54Z piman $
24
from tempfile import NamedTemporaryFile
26
from browsers._base import Browser
27
from formats._audio import AudioFile
28
from library import library
29
from qltk.songsmenu import SongsMenu
30
from qltk.views import RCMHintedTreeView
31
from qltk.wlw import WaitLoadWindow
32
from util.uri import URI
34
PLAYLISTS = os.path.join(const.USERDIR, "playlists")
35
if not os.path.isdir(PLAYLISTS): util.mkdir(PLAYLISTS)
37
def ParseM3U(filename):
38
plname = util.fsdecode(os.path.basename(
39
os.path.splitext(filename)[0])).encode('utf-8')
41
for line in file(filename):
43
if line.startswith("#"): continue
44
else: filenames.append(line)
45
return __ParsePlaylist(plname, filename, filenames)
47
def ParsePLS(filename, name=""):
48
plname = util.fsdecode(os.path.basename(
49
os.path.splitext(filename)[0])).encode('utf-8')
51
for line in file(filename):
53
if not line.lower().startswith("file"): continue
55
try: line = line[line.index("=")+1:].strip()
56
except ValueError: pass
57
else: filenames.append(line)
58
return __ParsePlaylist(plname, filename, filenames)
60
def __ParsePlaylist(name, plfilename, files):
61
playlist = Playlist.new(name)
64
None, len(files), _("Importing playlist.\n\n%d/%d songs added."),
66
for i, filename in enumerate(files):
67
try: uri = URI(filename)
70
filename = os.path.realpath(os.path.join(
71
os.path.dirname(plfilename), filename))
72
if filename in library: songs.append(library[filename])
73
else: songs.append(formats.MusicFile(filename))
75
if uri.scheme == "file":
76
# URI-encoded local filename.
77
filename = os.path.realpath(os.path.join(
78
os.path.dirname(plfilename), uri.filename))
79
if filename in library: songs.append(library[filename])
80
else: songs.append(formats.MusicFile(filename))
82
# Who knows! Hand it off to GStreamer.
83
songs.append(formats.remote.RemoteFile(uri))
84
if win.step(i, len(files)): break
86
playlist.extend(filter(None, songs))
90
quote = staticmethod(lambda text: urllib.quote(text, safe=""))
91
unquote = staticmethod(urllib.unquote)
93
def new(klass, base=_("New Playlist")):
100
try: p.rename("%s %d" % (base, i))
101
except ValueError: pass
103
new = classmethod(new)
105
def fromsongs(klass, songs):
106
if len(songs) == 1: title = songs[0].comma("title")
107
else: title = _("%(title)s and %(count)d more") % (
108
{'title':songs[0].comma("title"), 'count':len(songs) - 1})
109
playlist = klass.new(title)
110
playlist.extend(songs)
112
fromsongs = classmethod(fromsongs)
114
def __init__(self, name):
115
super(Playlist, self).__init__()
116
if isinstance(name, unicode): name = name.encode('utf-8')
118
basename = self.quote(name)
120
for line in file(os.path.join(PLAYLISTS, basename), "r"):
122
if line in library: self.append(library[line])
123
elif library.masked(line): self.append(line)
125
if self.name: self.write()
127
def rename(self, newname):
128
if isinstance(newname, unicode): newname = newname.encode('utf-8')
129
if newname == self.name: return
130
elif os.path.exists(os.path.join(PLAYLISTS, self.quote(newname))):
132
_("A playlist named %s already exists.") % newname)
134
try: os.unlink(os.path.join(PLAYLISTS, self.quote(self.name)))
135
except EnvironmentError: pass
139
def add_songs(self, filenames):
141
for i in range(len(self)):
142
if isinstance(self[i], basestring) and self[i] in filenames:
143
self[i] = library[self[i]]
147
def remove_songs(self, songs):
150
if library.masked(song("~filename")):
152
try: self[self.index(song)] = song("~filename")
153
except ValueError: break
156
while song in self: self.remove(song)
162
try: os.unlink(os.path.join(PLAYLISTS, self.quote(self.name)))
163
except EnvironmentError: pass
166
basename = self.quote(self.name)
167
f = file(os.path.join(PLAYLISTS, basename), "w")
169
try: f.write(song("~filename") + "\n")
170
except TypeError: f.write(song + "\n")
174
return "<b>%s</b>\n<small>%s (%s)</small>" % (
175
util.escape(self.name),
176
ngettext("%d song", "%d songs", len(self)) % len(self),
177
util.format_time(sum([t.get("~#length") for t in self
178
if isinstance(t, AudioFile)])))
180
def __cmp__(self, other):
181
try: return cmp(self.name, other.name)
182
except AttributeError: return -1
184
class Menu(gtk.Menu):
185
def __init__(self, songs):
186
super(Menu, self).__init__()
187
i = gtk.MenuItem(_("_New Playlist"))
188
i.connect_object('activate', self.__add_to_playlist, None, songs)
190
self.append(gtk.SeparatorMenuItem())
191
self.set_size_request(int(i.size_request()[0] * 2), -1)
193
for playlist in Playlists.playlists():
194
i = gtk.MenuItem(playlist.name)
195
i.child.set_ellipsize(pango.ELLIPSIZE_END)
197
'activate', self.__add_to_playlist, playlist, songs)
200
def __add_to_playlist(playlist, songs):
202
if len(songs) == 1: title = songs[0].comma("title")
203
else: title = _("%(title)s and %(count)d more") % (
204
{'title':songs[0].comma("title"), 'count':len(songs) - 1})
205
playlist = Playlist.new(title)
206
playlist.extend(songs)
207
Playlists.changed(playlist)
208
__add_to_playlist = staticmethod(__add_to_playlist)
210
class Playlists(gtk.VBox, Browser):
211
__gsignals__ = Browser.__gsignals__
212
expand = qltk.RHPaned
214
name = _("Playlists")
215
accelerated_name = _("_Playlists")
217
replaygain_profiles = ["track"]
219
def init(klass, library):
220
model = klass.__lists.get_model()
221
for playlist in os.listdir(PLAYLISTS):
222
try: model.append(row=[Playlist(Playlist.unquote(playlist))])
223
except EnvironmentError: pass
224
library.connect('removed', klass.__removed)
225
library.connect('added', klass.__added)
226
library.connect('changed', klass.__changed)
227
init = classmethod(init)
229
def playlists(klass): return [row[0] for row in klass.__lists]
230
playlists = classmethod(playlists)
232
def changed(klass, playlist, refresh=True):
233
model = klass.__lists
235
if row[0] is playlist:
237
klass.__lists.row_changed(row.path, row.iter)
241
model.get_model().append(row=[playlist])
243
changed = classmethod(changed)
245
def __removed(klass, library, songs):
246
for playlist in klass.playlists():
247
if playlist.remove_songs(songs): Playlists.changed(playlist)
248
__removed = classmethod(__removed)
250
def __added(klass, library, songs):
251
filenames = set([song("~filename") for song in songs])
252
for playlist in klass.playlists():
253
if playlist.add_songs(filenames):
254
Playlists.changed(playlist)
255
__added = classmethod(__added)
257
def __changed(klass, library, songs):
258
for playlist in klass.playlists():
261
Playlists.changed(playlist, refresh=False)
263
__changed = classmethod(__changed)
265
def cell_data(col, render, model, iter):
266
render.markup = model[iter][0].format()
267
render.set_property('markup', render.markup)
268
cell_data = staticmethod(cell_data)
270
def Menu(self, songs, songlist, library):
271
menu = super(Playlists, self).Menu(songs, songlist, library)
272
model, rows = songlist.get_selection().get_selected_rows()
273
iters = map(model.get_iter, rows)
274
i = qltk.MenuItem(_("_Remove from Playlist"), gtk.STOCK_REMOVE)
275
i.connect_object('activate', self.__remove, iters, model)
276
i.set_sensitive(bool(self.__view.get_selection().get_selected()[1]))
281
__lists = gtk.TreeModelSort(gtk.ListStore(object))
282
__lists.set_default_sort_func(lambda m, a, b: cmp(m[a][0], m[b][0]))
284
def __init__(self, library, player):
285
super(Playlists, self).__init__(spacing=6)
286
self.__main = bool(player)
287
self.__view = view = RCMHintedTreeView()
288
self.__view.set_enable_search(True)
289
self.__view.set_search_column(0)
290
self.__view.set_search_equal_func(
291
lambda model, col, key, iter:
292
not model[iter][col].name.lower().startswith(key.lower()))
293
self.__render = render = gtk.CellRendererText()
294
render.set_property('ellipsize', pango.ELLIPSIZE_END)
295
render.connect('editing-started', self.__start_editing)
296
render.connect('edited', self.__edited)
297
col = gtk.TreeViewColumn("Playlists", render)
298
col.set_cell_data_func(render, Playlists.cell_data)
299
view.append_column(col)
300
view.set_model(self.__lists)
301
view.set_rules_hint(True)
302
view.set_headers_visible(False)
303
swin = gtk.ScrolledWindow()
304
swin.set_shadow_type(gtk.SHADOW_IN)
305
swin.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
307
self.pack_start(swin)
309
newpl = gtk.Button(stock=gtk.STOCK_NEW)
310
newpl.connect('clicked', self.__new_playlist)
311
importpl = qltk.Button(_("_Import"), gtk.STOCK_ADD)
312
importpl.connect('clicked', self.__import, library)
313
hb = gtk.HBox(spacing=6)
314
hb.set_homogeneous(True)
316
hb.pack_start(importpl)
317
self.pack_start(hb, expand=False)
319
view.connect('popup-menu', self.__popup_menu, library)
321
targets = [("text/x-quodlibet-songs", gtk.TARGET_SAME_APP, 0),
322
("text/uri-list", 0, 1),
323
("text/x-moz-url", 0, 2)]
324
view.drag_dest_set(gtk.DEST_DEFAULT_ALL, targets,
325
gtk.gdk.ACTION_COPY|gtk.gdk.ACTION_DEFAULT)
326
view.connect('drag-data-received', self.__drag_data_received, library)
327
view.connect('drag-motion', self.__drag_motion)
328
view.connect('drag-leave', self.__drag_leave)
329
if player: view.connect('row-activated', self.__play, player)
330
else: render.set_property('editable', True)
331
view.get_selection().connect('changed', self.activate)
333
s = view.get_model().connect('row-changed', self.__check_current)
334
self.connect_object('destroy', view.get_model().disconnect, s)
336
self.accelerators = gtk.AccelGroup()
337
keyval, mod = gtk.accelerator_parse("F2")
338
self.accelerators.connect_group(keyval, mod, 0, self.__rename)
342
def __rename(self, group, acceleratable, keyval, modifier):
343
model, iter = self.__view.get_selection().get_selected()
345
self.__render.set_property('editable', True)
346
self.__view.set_cursor(model.get_path(iter),
347
self.__view.get_columns()[0],
350
def __play(self, view, path, column, player):
353
def __check_current(self, model, path, iter):
354
model, citer = self.__view.get_selection().get_selected()
355
if citer and model.get_path(citer) == path:
356
songlist = qltk.get_top_parent(self).songlist
357
self.activate(resort=not songlist.is_sorted())
359
def __drag_motion(self, view, ctx, x, y, time):
360
if "text/x-quodlibet-songs" in ctx.targets:
361
try: path = view.get_dest_row_at_pos(x, y)[0]
363
path = (len(view.get_model()) - 1,)
364
pos = gtk.TREE_VIEW_DROP_AFTER
365
else: pos = gtk.TREE_VIEW_DROP_INTO_OR_AFTER
366
if path > (-1,): view.set_drag_dest_row(path, pos)
369
# Highlighting the view itself doesn't work.
370
view.parent.drag_highlight()
373
def __drag_leave(self, view, ctx, time):
374
view.parent.drag_unhighlight()
376
def __remove(self, iters, smodel):
377
model, iter = self.__view.get_selection().get_selected()
379
map(smodel.remove, iters)
380
playlist = model[iter][0]
382
for row in smodel: playlist.append(row[0])
383
Playlists.changed(playlist)
386
def __drag_data_received(self, view, ctx, x, y, sel, tid, etime, library):
387
# TreeModelSort doesn't support GtkTreeDragDestDrop.
388
view.emit_stop_by_name('drag-data-received')
389
model = view.get_model()
391
filenames = sel.data.split("\x00")
392
songs = filter(None, map(library.get, filenames))
393
if not songs: return True
394
try: path, pos = view.get_dest_row_at_pos(x, y)
396
playlist = Playlist.fromsongs(songs)
397
gobject.idle_add(self.__select_playlist, playlist)
399
playlist = model[path][0]
400
playlist.extend(songs)
401
Playlists.changed(playlist)
402
ctx.finish(True, False, etime)
405
uri = sel.get_uris()[0]
406
name = os.path.basename(uri)
408
uri, name = sel.data.decode('utf16', 'replace').split('\n')
410
ctx.finish(False, False, etime)
412
name = name or os.path.basename(uri) or _("New Playlist")
413
uri = uri.encode('utf-8')
414
sock = urllib.urlopen(uri)
415
f = NamedTemporaryFile()
416
f.write(sock.read()); f.flush()
417
if uri.lower().endswith('.pls'): playlist = ParsePLS(f.name)
418
elif uri.lower().endswith('.m3u'): playlist = ParseM3U(f.name)
419
else: playlist = None
421
library.add_filename(playlist)
422
if name: playlist.rename(name)
423
Playlists.changed(playlist)
424
ctx.finish(True, False, etime)
426
ctx.finish(False, False, etime)
428
qltk.get_top_parent(self),
429
_("Unable to import playlist"),
430
_("Quod Libet can only import playlists in the M3U "
431
"and PLS formats.")).run()
433
def __select_playlist(self, playlist):
435
model = view.get_model()
437
if row[0] is playlist:
438
view.get_selection().select_iter(row.iter)
440
def __popup_menu(self, view, library):
441
model, iter = view.get_selection().get_selected()
444
songs = list(model[iter][0])
445
menu = SongsMenu(library, songs, playlists=False, remove=False)
448
rem = gtk.ImageMenuItem(gtk.STOCK_DELETE)
449
def remove(model, iter):
450
model[iter][0].delete()
451
model.get_model().remove(
452
model.convert_iter_to_child_iter(None, iter))
453
rem.connect_object('activate', remove, model, iter)
456
ren = gtk.ImageMenuItem(stock.RENAME)
457
keyval, mod = gtk.accelerator_parse("F2")
459
'activate', self.accelerators, keyval, mod, gtk.ACCEL_VISIBLE)
461
self.__render.set_property('editable', True)
462
view.set_cursor(path, view.get_columns()[0], start_editing=True)
463
ren.connect_object('activate', rename, model.get_path(iter))
467
return view.popup_menu(menu, 0, gtk.get_current_event_time())
469
def activate(self, widget=None, resort=True):
470
model, iter = self.__view.get_selection().get_selected()
471
songs = iter and list(model[iter][0]) or []
472
songs = filter(lambda s: isinstance(s, AudioFile), songs)
473
name = iter and model[iter][0].name or ""
474
if self.__main: config.set("browsers", "playlist", name)
475
self.emit('songs-selected', songs, resort)
477
def __new_playlist(self, activator):
478
playlist = Playlist.new()
479
self.__lists.get_model().append(row=[playlist])
480
self.__select_playlist(playlist)
482
def __start_editing(self, render, editable, path):
483
editable.set_text(self.__lists[path][0].name)
485
def __edited(self, render, path, newname):
486
try: self.__lists[path][0].rename(newname)
487
except ValueError, s:
489
None, _("Unable to rename playlist"), s).run()
490
else: self.__lists[path] = self.__lists[path]
491
render.set_property('editable', not self.__main)
493
def __import(self, activator, library):
494
filt = lambda fn: fn.endswith(".pls") or fn.endswith(".m3u")
495
from qltk.chooser import FileChooser
496
chooser = FileChooser(self, _("Import Playlist"), filt, const.HOME)
497
files = chooser.run()
499
for filename in files:
500
if filename.endswith(".m3u"):
501
playlist = ParseM3U(filename)
502
elif filename.endswith(".pls"):
503
playlist = ParsePLS(filename)
506
qltk.get_top_parent(self),
507
_("Unable to import playlist"),
508
_("Quod Libet can only import playlists in the M3U "
509
"and PLS formats.")).run()
511
Playlists.changed(playlist)
512
library.add(playlist)
515
try: name = config.get("browsers", "playlist")
518
for i, row in enumerate(self.__lists):
519
if row[0].name == name:
520
self.__view.get_selection().select_path((i,))
523
def reordered(self, songlist):
524
songs = songlist.get_songs()
525
model, iter = self.__view.get_selection().get_selected()
527
playlist = model[iter][0]
530
playlist = Playlist.fromsongs(songs)
531
gobject.idle_add(self.__select_playlist, playlist)
532
Playlists.changed(playlist, refresh=False)
534
browsers = [Playlists]