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 4330 2008-09-14 03:19:26Z piman $
17
from quodlibet import config
18
from quodlibet import const
19
from quodlibet import formats
20
from quodlibet import qltk
21
from quodlibet import stock
22
from quodlibet import util
24
from tempfile import NamedTemporaryFile
26
from quodlibet.browsers._base import Browser
27
from quodlibet.formats._audio import AudioFile
28
from quodlibet.qltk.songsmenu import SongsMenu
29
from quodlibet.qltk.views import RCMHintedTreeView
30
from quodlibet.qltk.wlw import WaitLoadWindow
31
from quodlibet.util.uri import URI
33
PLAYLISTS = os.path.join(const.USERDIR, "playlists")
34
if not os.path.isdir(PLAYLISTS): util.mkdir(PLAYLISTS)
36
def ParseM3U(filename, library=None):
37
plname = util.fsdecode(os.path.basename(
38
os.path.splitext(filename)[0])).encode('utf-8')
40
for line in file(filename):
42
if line.startswith("#"): continue
43
else: filenames.append(line)
44
return __ParsePlaylist(plname, filename, filenames, library)
46
def ParsePLS(filename, name="", library=None):
47
plname = util.fsdecode(os.path.basename(
48
os.path.splitext(filename)[0])).encode('utf-8')
50
for line in file(filename):
52
if not line.lower().startswith("file"): continue
54
try: line = line[line.index("=")+1:].strip()
55
except ValueError: pass
56
else: filenames.append(line)
57
return __ParsePlaylist(plname, filename, filenames, library)
59
def __ParsePlaylist(name, plfilename, files, library):
60
playlist = Playlist.new(name, library=library)
64
_("Importing playlist.\n\n%(current)d/%(total)d songs added."))
65
for i, filename in enumerate(files):
66
try: uri = URI(filename)
69
filename = os.path.realpath(os.path.join(
70
os.path.dirname(plfilename), filename))
71
if library and filename in library:
72
songs.append(library[filename])
74
songs.append(formats.MusicFile(filename))
76
if uri.scheme == "file":
77
# URI-encoded local filename.
78
filename = os.path.realpath(os.path.join(
79
os.path.dirname(plfilename), uri.filename))
80
if library and filename in library:
81
songs.append(library[filename])
83
songs.append(formats.MusicFile(filename))
85
# Who knows! Hand it off to GStreamer.
86
songs.append(formats.remote.RemoteFile(uri))
89
playlist.extend(filter(None, songs))
93
quote = staticmethod(lambda text: urllib.quote(text, safe=""))
94
unquote = staticmethod(urllib.unquote)
96
def new(klass, base=_("New Playlist"), library={}):
97
p = Playlist("", library=library)
103
try: p.rename("%s %d" % (base, i))
104
except ValueError: pass
106
new = classmethod(new)
108
def fromsongs(klass, songs, library={}):
110
title = songs[0].comma("title")
113
"%(title)s and %(count)d more",
114
"%(title)s and %(count)d more",
116
{'title': songs[0].comma("title"), 'count': len(songs) - 1})
117
playlist = klass.new(title, library=library)
118
playlist.extend(songs)
120
fromsongs = classmethod(fromsongs)
122
def __init__(self, name, library=None):
123
super(Playlist, self).__init__()
124
if isinstance(name, unicode): name = name.encode('utf-8')
126
basename = self.quote(name)
128
for line in file(os.path.join(PLAYLISTS, basename), "r"):
131
self.append(library[line])
132
elif library and library.masked(line):
135
if self.name: self.write()
137
def rename(self, newname):
138
if isinstance(newname, unicode): newname = newname.encode('utf-8')
139
if newname == self.name: return
140
elif os.path.exists(os.path.join(PLAYLISTS, self.quote(newname))):
142
_("A playlist named %s already exists.") % newname)
144
try: os.unlink(os.path.join(PLAYLISTS, self.quote(self.name)))
145
except EnvironmentError: pass
149
def add_songs(self, filenames, library):
151
for i in range(len(self)):
152
if isinstance(self[i], basestring) and self[i] in filenames:
153
self[i] = library[self[i]]
157
def remove_songs(self, songs, library):
159
# TODO: document the "library.masked" business
161
if library.masked(song("~filename")):
163
try: self[self.index(song)] = song("~filename")
164
except ValueError: break
167
while song in self: self.remove(song)
171
def has_songs(self, songs):
172
# TODO(rm): consider the "library.masked" business
173
some, all = False, True
184
try: os.unlink(os.path.join(PLAYLISTS, self.quote(self.name)))
185
except EnvironmentError: pass
188
basename = self.quote(self.name)
189
f = file(os.path.join(PLAYLISTS, basename), "w")
191
try: f.write(song("~filename") + "\n")
192
except TypeError: f.write(song + "\n")
196
return "<b>%s</b>\n<small>%s (%s)</small>" % (
197
util.escape(self.name),
198
ngettext("%d song", "%d songs", len(self)) % len(self),
199
util.format_time(sum([t.get("~#length") for t in self
200
if isinstance(t, AudioFile)])))
202
def __cmp__(self, other):
203
try: return cmp(self.name, other.name)
204
except AttributeError: return -1
206
class Menu(gtk.Menu):
207
def __init__(self, songs):
208
super(Menu, self).__init__()
209
i = gtk.MenuItem(_("_New Playlist"))
210
i.connect_object('activate', self.__add_to_playlist, None, songs)
212
self.append(gtk.SeparatorMenuItem())
213
self.set_size_request(int(i.size_request()[0] * 2), -1)
215
for playlist in Playlists.playlists():
217
i = gtk.CheckMenuItem(name)
218
some, all = playlist.has_songs(songs)
220
i.set_inconsistent(some and not all)
221
i.child.set_ellipsize(pango.ELLIPSIZE_END)
223
'activate', self.__add_to_playlist, playlist, songs)
226
def __add_to_playlist(playlist, songs):
229
title = songs[0].comma("title")
232
"%(title)s and %(count)d more",
233
"%(title)s and %(count)d more",
235
{'title': songs[0].comma("title"), 'count': len(songs) - 1})
236
playlist = Playlist.new(title)
237
playlist.extend(songs)
238
Playlists.changed(playlist)
239
__add_to_playlist = staticmethod(__add_to_playlist)
241
class Playlists(gtk.VBox, Browser):
242
__gsignals__ = Browser.__gsignals__
243
expand = qltk.RHPaned
245
name = _("Playlists")
246
accelerated_name = _("_Playlists")
248
replaygain_profiles = ["track"]
250
def init(klass, library):
251
model = klass.__lists.get_model()
252
for playlist in os.listdir(PLAYLISTS):
254
playlist = Playlist(Playlist.unquote(playlist), library=library)
255
model.append(row=[playlist])
256
except EnvironmentError:
258
library.connect('removed', klass.__removed)
259
library.connect('added', klass.__added)
260
library.connect('changed', klass.__changed)
261
init = classmethod(init)
263
def playlists(klass): return [row[0] for row in klass.__lists]
264
playlists = classmethod(playlists)
266
def changed(klass, playlist, refresh=True):
267
model = klass.__lists
269
if row[0] is playlist:
271
klass.__lists.row_changed(row.path, row.iter)
275
model.get_model().append(row=[playlist])
277
changed = classmethod(changed)
279
def __removed(klass, library, songs):
280
for playlist in klass.playlists():
281
if playlist.remove_songs(songs, library):
282
Playlists.changed(playlist)
283
__removed = classmethod(__removed)
285
def __added(klass, library, songs):
286
filenames = set([song("~filename") for song in songs])
287
for playlist in klass.playlists():
288
if playlist.add_songs(filenames, library):
289
Playlists.changed(playlist)
290
__added = classmethod(__added)
292
def __changed(klass, library, songs):
293
for playlist in klass.playlists():
296
Playlists.changed(playlist, refresh=False)
298
__changed = classmethod(__changed)
300
def cell_data(col, render, model, iter):
301
render.markup = model[iter][0].format()
302
render.set_property('markup', render.markup)
303
cell_data = staticmethod(cell_data)
305
def Menu(self, songs, songlist, library):
306
menu = super(Playlists, self).Menu(songs, songlist, library)
307
model, rows = songlist.get_selection().get_selected_rows()
308
iters = map(model.get_iter, rows)
309
i = qltk.MenuItem(_("_Remove from Playlist"), gtk.STOCK_REMOVE)
310
i.connect_object('activate', self.__remove, iters, model)
311
i.set_sensitive(bool(self.__view.get_selection().get_selected()[1]))
316
__lists = gtk.TreeModelSort(gtk.ListStore(object))
317
__lists.set_default_sort_func(lambda m, a, b: cmp(m[a][0], m[b][0]))
319
def __init__(self, library, player):
320
super(Playlists, self).__init__(spacing=6)
321
self.__main = bool(player)
322
self.__view = view = RCMHintedTreeView()
323
self.__view.set_enable_search(True)
324
self.__view.set_search_column(0)
325
self.__view.set_search_equal_func(
326
lambda model, col, key, iter:
327
not model[iter][col].name.lower().startswith(key.lower()))
328
self.__render = render = gtk.CellRendererText()
329
render.set_property('ellipsize', pango.ELLIPSIZE_END)
330
render.connect('editing-started', self.__start_editing)
331
render.connect('edited', self.__edited)
332
col = gtk.TreeViewColumn("Playlists", render)
333
col.set_cell_data_func(render, Playlists.cell_data)
334
view.append_column(col)
335
view.set_model(self.__lists)
336
view.set_rules_hint(True)
337
view.set_headers_visible(False)
338
swin = gtk.ScrolledWindow()
339
swin.set_shadow_type(gtk.SHADOW_IN)
340
swin.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
342
self.pack_start(swin)
344
newpl = gtk.Button(stock=gtk.STOCK_NEW)
345
newpl.connect('clicked', self.__new_playlist)
346
importpl = qltk.Button(_("_Import"), gtk.STOCK_ADD)
347
importpl.connect('clicked', self.__import, library)
348
hb = gtk.HBox(spacing=6)
349
hb.set_homogeneous(True)
351
hb.pack_start(importpl)
352
self.pack_start(hb, expand=False)
354
view.connect('popup-menu', self.__popup_menu, library)
356
targets = [("text/x-quodlibet-songs", gtk.TARGET_SAME_APP, 0),
357
("text/uri-list", 0, 1),
358
("text/x-moz-url", 0, 2)]
359
view.drag_dest_set(gtk.DEST_DEFAULT_ALL, targets,
360
gtk.gdk.ACTION_COPY|gtk.gdk.ACTION_DEFAULT)
361
view.connect('drag-data-received', self.__drag_data_received, library)
362
view.connect('drag-motion', self.__drag_motion)
363
view.connect('drag-leave', self.__drag_leave)
364
if player: view.connect('row-activated', self.__play, player)
365
else: render.set_property('editable', True)
366
view.get_selection().connect('changed', self.activate)
368
s = view.get_model().connect('row-changed', self.__check_current)
369
self.connect_object('destroy', view.get_model().disconnect, s)
371
self.accelerators = gtk.AccelGroup()
372
keyval, mod = gtk.accelerator_parse("F2")
373
self.accelerators.connect_group(keyval, mod, 0, self.__rename)
377
def __rename(self, group, acceleratable, keyval, modifier):
378
model, iter = self.__view.get_selection().get_selected()
380
self.__render.set_property('editable', True)
381
self.__view.set_cursor(model.get_path(iter),
382
self.__view.get_columns()[0],
385
def __play(self, view, path, column, player):
388
def __check_current(self, model, path, iter):
389
model, citer = self.__view.get_selection().get_selected()
390
if citer and model.get_path(citer) == path:
391
songlist = qltk.get_top_parent(self).songlist
392
self.activate(resort=not songlist.is_sorted())
394
def __drag_motion(self, view, ctx, x, y, time):
395
if "text/x-quodlibet-songs" in ctx.targets:
396
try: path = view.get_dest_row_at_pos(x, y)[0]
398
path = (len(view.get_model()) - 1,)
399
pos = gtk.TREE_VIEW_DROP_AFTER
400
else: pos = gtk.TREE_VIEW_DROP_INTO_OR_AFTER
401
if path > (-1,): view.set_drag_dest_row(path, pos)
404
# Highlighting the view itself doesn't work.
405
view.parent.drag_highlight()
408
def __drag_leave(self, view, ctx, time):
409
view.parent.drag_unhighlight()
411
def __remove(self, iters, smodel):
412
model, iter = self.__view.get_selection().get_selected()
414
map(smodel.remove, iters)
415
playlist = model[iter][0]
417
for row in smodel: playlist.append(row[0])
418
Playlists.changed(playlist)
421
def __drag_data_received(self, view, ctx, x, y, sel, tid, etime, library):
422
# TreeModelSort doesn't support GtkTreeDragDestDrop.
423
view.emit_stop_by_name('drag-data-received')
424
model = view.get_model()
426
filenames = sel.data.split("\x00")
427
songs = filter(None, map(library.get, filenames))
428
if not songs: return True
429
try: path, pos = view.get_dest_row_at_pos(x, y)
431
playlist = Playlist.fromsongs(songs, library)
432
gobject.idle_add(self.__select_playlist, playlist)
434
playlist = model[path][0]
435
playlist.extend(songs)
436
Playlists.changed(playlist)
437
ctx.finish(True, False, etime)
440
uri = sel.get_uris()[0]
441
name = os.path.basename(uri)
443
uri, name = sel.data.decode('utf16', 'replace').split('\n')
445
ctx.finish(False, False, etime)
447
name = name or os.path.basename(uri) or _("New Playlist")
448
uri = uri.encode('utf-8')
449
sock = urllib.urlopen(uri)
450
f = NamedTemporaryFile()
451
f.write(sock.read()); f.flush()
452
if uri.lower().endswith('.pls'):
453
playlist = ParsePLS(f.name, library=library)
454
elif uri.lower().endswith('.m3u'):
455
playlist = ParseM3U(f.name, library=library)
456
else: playlist = None
458
library.add_filename(playlist)
459
if name: playlist.rename(name)
460
Playlists.changed(playlist)
461
ctx.finish(True, False, etime)
463
ctx.finish(False, False, etime)
465
qltk.get_top_parent(self),
466
_("Unable to import playlist"),
467
_("Quod Libet can only import playlists in the M3U "
468
"and PLS formats.")).run()
470
def __select_playlist(self, playlist):
472
model = view.get_model()
474
if row[0] is playlist:
475
view.get_selection().select_iter(row.iter)
477
def __popup_menu(self, view, library):
478
model, iter = view.get_selection().get_selected()
481
songs = list(model[iter][0])
482
menu = SongsMenu(library, songs, playlists=False, remove=False)
485
rem = gtk.ImageMenuItem(gtk.STOCK_DELETE)
486
def remove(model, iter):
487
model[iter][0].delete()
488
model.get_model().remove(
489
model.convert_iter_to_child_iter(None, iter))
490
rem.connect_object('activate', remove, model, iter)
493
ren = gtk.ImageMenuItem(stock.RENAME)
494
keyval, mod = gtk.accelerator_parse("F2")
496
'activate', self.accelerators, keyval, mod, gtk.ACCEL_VISIBLE)
498
self.__render.set_property('editable', True)
499
view.set_cursor(path, view.get_columns()[0], start_editing=True)
500
ren.connect_object('activate', rename, model.get_path(iter))
504
return view.popup_menu(menu, 0, gtk.get_current_event_time())
506
def activate(self, widget=None, resort=True):
507
model, iter = self.__view.get_selection().get_selected()
508
songs = iter and list(model[iter][0]) or []
509
songs = filter(lambda s: isinstance(s, AudioFile), songs)
510
name = iter and model[iter][0].name or ""
511
if self.__main: config.set("browsers", "playlist", name)
512
self.emit('songs-selected', songs, resort)
514
def __new_playlist(self, activator):
515
playlist = Playlist.new()
516
self.__lists.get_model().append(row=[playlist])
517
self.__select_playlist(playlist)
519
def __start_editing(self, render, editable, path):
520
editable.set_text(self.__lists[path][0].name)
522
def __edited(self, render, path, newname):
523
try: self.__lists[path][0].rename(newname)
524
except ValueError, s:
526
None, _("Unable to rename playlist"), s).run()
527
else: self.__lists[path] = self.__lists[path]
528
render.set_property('editable', not self.__main)
530
def __import(self, activator, library):
531
filt = lambda fn: fn.endswith(".pls") or fn.endswith(".m3u")
532
from qltk.chooser import FileChooser
533
chooser = FileChooser(self, _("Import Playlist"), filt, const.HOME)
534
files = chooser.run()
536
for filename in files:
537
if filename.endswith(".m3u"):
538
playlist = ParseM3U(filename, library=library)
539
elif filename.endswith(".pls"):
540
playlist = ParsePLS(filename, library=library)
543
qltk.get_top_parent(self),
544
_("Unable to import playlist"),
545
_("Quod Libet can only import playlists in the M3U "
546
"and PLS formats.")).run()
548
Playlists.changed(playlist)
549
library.add(playlist)
552
try: name = config.get("browsers", "playlist")
555
for i, row in enumerate(self.__lists):
556
if row[0].name == name:
557
self.__view.get_selection().select_path((i,))
560
def reordered(self, songlist):
561
songs = songlist.get_songs()
562
model, iter = self.__view.get_selection().get_selected()
564
playlist = model[iter][0]
567
playlist = Playlist.fromsongs(songs)
568
gobject.idle_add(self.__select_playlist, playlist)
569
Playlists.changed(playlist, refresh=False)
571
browsers = [Playlists]