1
# Miro - an RSS based video player application
2
# Copyright (C) 2005-2010 Participatory Culture Foundation
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18
# In addition, as a special exception, the copyright holders give
19
# permission to link the code of portions of this program with the OpenSSL
22
# You must obey the GNU General Public License in all respects for all of
23
# the code used other than OpenSSL. If you modify file(s) with this
24
# exception, you may extend this exception to your version of the file(s),
25
# but you are not obligated to do so. If you do not wish to do so, delete
26
# this exception statement from your version. If you delete this exception
27
# statement from all source files in the program, then also delete it here.
29
"""itemlist.py -- Handles TableModel objects that store items.
31
itemlist, itemlistcontroller and itemlistwidgets work together using the MVC
32
pattern. itemlist handles the Model, itemlistwidgets handles the View and
33
itemlistcontroller handles the Controller.
35
ItemList manages a TableModel that stores ItemInfo objects. It handles
36
filtering out items from the list (for example in the Downloading items list).
37
They also handle temporarily filtering out items based the user's search
45
from miro import search
46
from miro import signals
48
from miro.frontends.widgets import imagepool
49
from miro.plat.utils import filename_to_unicode
50
from miro.plat.frontends.widgets import timer
51
from miro.plat.frontends.widgets import widgetset
53
def item_matches_search(item_info, search_text):
54
"""Check if an item matches search text."""
57
match_against = [item_info.name, item_info.description]
58
if item_info.video_path is not None:
59
match_against.append(filename_to_unicode(item_info.video_path))
60
return search.match(search_text, match_against)
62
class ItemSort(object):
63
"""Class that sorts items in an item list."""
65
def __init__(self, ascending):
66
self._reverse = not ascending
68
def is_ascending(self):
69
return not self._reverse
71
def sort_key(self, item):
72
"""Return a value that can be used to sort item.
74
Must be implemented by subclasses.
76
raise NotImplementedError()
78
def compare(self, item, other):
81
Returns -1 if item < other, 1 if other > item and 0 if item == other
85
return -cmp(self.sort_key(item), self.sort_key(other))
87
return cmp(self.sort_key(item), self.sort_key(other))
89
def sort_items(self, item_list):
90
"""Sort a list of items (in place)."""
91
item_list.sort(key=self.sort_key, reverse=self._reverse)
93
def sort_item_rows(self, rows):
94
rows.sort(key=lambda row: self.sort_key(row[0]),
95
reverse=self._reverse)
97
class DateSort(ItemSort):
99
def sort_key(self, item):
100
return item.release_date
102
class NameSort(ItemSort):
104
def sort_key(self, item):
105
return util.name_sort_key(item.name)
107
class LengthSort(ItemSort):
109
def sort_key(self, item):
112
class SizeSort(ItemSort):
114
def sort_key(self, item):
117
class DescriptionSort(ItemSort):
119
def sort_key(self, item):
120
return item.description
122
class FeedNameSort(ItemSort):
124
def sort_key(self, item):
126
return item.feed_name.lower()
127
return item.feed_name
129
class StatusCircleSort(ItemSort):
131
# Weird sort, this one is for when the user clicks on the header above the
132
# status bumps. It's almost the same as StatusSort, but there isn't a
134
def sort_key(self, item):
135
if item.state == 'downloading':
136
return 1 # downloading
137
elif item.downloaded and not item.video_watched:
139
elif not item.item_viewed and not item.expiration_date:
144
class StatusSort(ItemSort):
146
def sort_key(self, item):
147
if item.state == 'downloading':
148
return (2, ) # downloading
149
elif item.downloaded and not item.video_watched:
150
return (3, ) # unwatched
151
elif item.expiration_date:
152
# the tuple here creates a subsort on expiration_date
153
return (4, item.expiration_date) # expiring
154
elif not item.item_viewed:
159
class ETASort(ItemSort):
161
def sort_key(self, item):
162
if item.state == 'downloading':
163
eta = item.download_info.eta
166
elif not self._reverse:
171
class DownloadRateSort(ItemSort):
173
def sort_key(self, item):
174
if item.state == 'downloading':
175
return item.download_info.rate
176
elif not self._reverse:
181
class ProgressSort(ItemSort):
183
def sort_key(self, item):
184
if item.state in ('downloading', 'paused'):
185
return float(item.download_info.downloaded_size) / item.size
186
elif not self._reverse:
192
DateSort.KEY: DateSort,
193
NameSort.KEY: NameSort,
194
LengthSort.KEY: LengthSort,
195
SizeSort.KEY: SizeSort,
196
DescriptionSort.KEY: DescriptionSort,
197
FeedNameSort.KEY: FeedNameSort,
198
StatusCircleSort.KEY: StatusCircleSort,
199
StatusSort.KEY: StatusSort,
200
ETASort.KEY: ETASort,
201
DownloadRateSort.KEY: DownloadRateSort,
202
ProgressSort.KEY: ProgressSort,
205
class ItemListGroup(object):
206
"""Manages a set of ItemLists.
208
ItemListGroup keep track of one or more ItemLists. When items are
209
added/changed/removed they take care of making sure each child list
212
ItemLists maintain an item sorting and a search filter that are shared by
216
def __init__(self, item_lists, sorter):
217
"""Construct in ItemLists.
219
item_lists is a list of ItemList objects that should be grouped
222
self.item_lists = item_lists
224
self.set_sort(DateSort(False))
226
self.set_sort(sorter)
227
self._throbber_timeouts = {}
228
self.html_stripper = util.HTMLStripper()
230
def _throbber_timeout(self, id):
231
for item_list in self.item_lists:
232
item_list.update_throbber(id)
233
self._schedule_throbber_timeout(id)
235
def _schedule_throbber_timeout(self, id):
236
timeout = timer.add(0.4, self._throbber_timeout, id)
237
self._throbber_timeouts[id] = timeout
239
def _setup_info(self, info):
240
"""Initialize a newly received ItemInfo."""
241
info.icon = imagepool.LazySurface(info.thumbnail, (154, 105))
242
info.description_text, info.description_links = \
243
self.html_stripper.strip(info.description)
244
download_info = info.download_info
245
if (download_info is not None and
246
not download_info.finished and
247
download_info.state != 'paused' and
248
download_info.downloaded_size > 0 and info.size == -1):
249
# We are downloading an item without a content-length. Take steps
250
# to update the progress throbbers.
251
if info.id not in self._throbber_timeouts:
252
self._schedule_throbber_timeout(info.id)
253
elif info.id in self._throbber_timeouts:
254
timer.cancel(self._throbber_timeouts.pop(info.id))
256
def add_items(self, item_list):
257
"""Add a list of new items to the item list.
259
Note: This method will sort item_list
261
self._sorter.sort_items(item_list)
262
for item_info in item_list:
263
self._setup_info(item_info)
264
for sublist in self.item_lists:
265
sublist.add_items(item_list, already_sorted=True)
267
def update_items(self, changed_items):
270
Note: This method will sort changed_items
272
self._sorter.sort_items(changed_items)
273
for item_info in changed_items:
274
self._setup_info(item_info)
275
for sublist in self.item_lists:
276
sublist.update_items(changed_items, already_sorted=True)
278
def remove_items(self, removed_ids):
279
"""Remove items from the list."""
280
for sublist in self.item_lists:
281
sublist.remove_items(removed_ids)
283
def set_sort(self, sorter):
284
"""Change the way items are sorted in the list (and filtered lists)
286
sorter must be a subclass of ItemSort.
288
self._sorter = sorter
289
for sublist in self.item_lists:
290
sublist.set_sort(sorter)
295
def set_search_text(self, search_text):
296
"""Update the search for each child list."""
297
for sublist in self.item_lists:
298
sublist.set_search_text(search_text)
300
class ItemList(signals.SignalEmitter):
304
model -- TableModel for this item list. It contains these columns:
306
* show_details flag (boolean)
307
* counter used to change the progress throbber (integer)
309
new_only -- Are we only displaying the new items?
310
unwatched_only -- Are we only displaying the unwatched items?
311
non_feed_only -- Are we only displaying file items?
312
resort_on_update -- Should we re-sort the list when items change?
315
item-added(item, next_item): an item was added to the list
319
signals.SignalEmitter.__init__(self)
320
self.create_signal('item-added')
321
self.model = widgetset.TableModel('object', 'boolean', 'integer')
324
self._search_text = ''
325
self.new_only = False
326
self.unwatched_only = False
327
self.non_feed_only = False
328
self.resort_on_update = False
329
self._hidden_items = {}
330
# maps ids -> items that should be in this list, but are filtered out
333
def set_sort(self, sorter):
334
self._sorter = sorter
341
"""Get the number of items in this list that are displayed."""
342
return len(self.model)
344
def get_hidden_count(self):
345
"""Get the number of items in this list that are hidden."""
346
return len(self._hidden_items)
348
def get_items(self, start_id=None):
349
"""Get a list of ItemInfo objects in this list"""
351
return [row[0] for row in self.model]
353
iter = self._iter_map[start_id]
355
while iter is not None:
356
retval.append(self.model[iter][0])
357
iter = self.model.next_iter(iter)
360
def _resort_items(self):
362
iter = self.model.first_iter()
363
while iter is not None:
364
rows.append(tuple(self.model[iter]))
365
iter = self.model.remove(iter)
366
self._sorter.sort_item_rows(rows)
368
self._iter_map[row[0].id] = self.model.append(*row)
370
def _resort_item(self, info):
371
"""Put an item into it's correct position using the current sort."""
372
itr = self._iter_map[info.id]
373
row = tuple(self.model[itr])
374
self.model.remove(itr)
376
itr = self.model.first_iter()
377
while itr is not None:
378
current_info = self.model[itr][0]
379
if(self._sorter.compare(info, current_info) < 1):
380
new_itr = self.model.insert_before(itr, *row)
382
itr = self.model.next_iter(itr)
384
new_itr = self.model.append(*row)
385
self._iter_map[info.id] = new_itr
387
def filter(self, item_info):
388
"""Can be overrided by subclasses to filter out items from the list.
392
def _should_show_item(self, item_info):
393
if not self.filter(item_info):
395
return (not (self.new_only and item_info.item_viewed) and
396
not (self.unwatched_only and item_info.video_watched) and
397
not (self.non_feed_only and (not item_info.is_external and item_info.feed_url != 'dtv:searchDownloads')) and
398
item_matches_search(item_info, self._search_text))
400
def set_show_details(self, item_id, value):
401
"""Change the show details value for an item"""
402
iter = self._iter_map[item_id]
403
self.model.update_value(iter, 1, value)
405
def find_show_details_rows(self):
406
"""Return a list of iters for rows with in show details mode."""
408
iter = self.model.first_iter()
409
while iter is not None:
410
if self.model[iter][1]:
412
iter = self.model.next_iter(iter)
415
def update_throbber(self, item_id):
417
iter = self._iter_map[item_id]
421
counter = self.model[iter][2]
422
self.model.update_value(iter, 2, counter + 1)
424
def _insert_sorted_items(self, item_list):
425
pos = self.model.first_iter()
426
for item_info in item_list:
427
while (pos is not None and
428
self._sorter.compare(self.model[pos][0], item_info) < 0):
429
pos = self.model.next_iter(pos)
430
iter = self.model.insert_before(pos, item_info, False, 0)
432
next_item_info = self.model[pos][0]
434
next_item_info = None
435
self.emit('item-added', item_info, next_item_info)
436
self._iter_map[item_info.id] = iter
438
def add_items(self, item_list, already_sorted=False):
440
for item in item_list:
441
if self._should_show_item(item):
444
self._hidden_items[item.id] = item
445
if not already_sorted:
446
self._sorter.sort_items(to_add)
447
self._insert_sorted_items(to_add)
449
def update_items(self, changed_items, already_sorted=False):
451
for info in changed_items:
452
should_show = self._should_show_item(info)
453
if info.id in self._iter_map:
454
# Item already displayed
456
self.remove_item(info.id)
457
self._hidden_items[info.id] = info
459
self.update_item(info)
461
# Item not already displayed
464
del self._hidden_items[info.id]
466
self._hidden_items[info.id] = info
467
if not already_sorted:
468
self._sorter.sort_items(to_add)
469
self._insert_sorted_items(to_add)
471
def remove_item(self, id):
473
iter = self._iter_map.pop(id)
476
del self._hidden_items[id]
478
self.model.remove(iter)
480
def update_item(self, info):
481
iter = self._iter_map[info.id]
482
old_item = self.model[iter][0]
483
self.model.update_value(iter, 0, info)
484
if self.resort_on_update and self._sorter.compare(info, old_item):
485
# If we've changed the sort value of the item, then we need to
486
# re-sort the list (#12003).
487
self._resort_item(info)
489
def remove_items(self, id_list):
493
def set_new_only(self, new_only):
494
"""Set if only new items are to be displayed (default False)."""
495
self.new_only = new_only
496
self._recalculate_hidden_items()
499
self.unwatched_only = False
500
self.non_feed_only = False
501
self._recalculate_hidden_items()
503
def toggle_unwatched_only(self):
504
self.unwatched_only = not self.unwatched_only
505
self._recalculate_hidden_items()
507
def toggle_non_feed(self):
508
self.non_feed_only = not self.non_feed_only
509
self._recalculate_hidden_items()
511
def set_filters(self, unwatched, non_feed):
512
self.unwatched_only = unwatched
513
self.non_feed_only = non_feed
514
self._recalculate_hidden_items()
516
def set_search_text(self, search_text):
517
self._search_text = search_text
518
self._recalculate_hidden_items()
520
def _recalculate_hidden_items(self):
521
newly_matching = self._find_newly_matching_items()
522
removed = self._remove_non_matching_items()
523
self._sorter.sort_items(newly_matching)
524
self._insert_sorted_items(newly_matching)
526
self._hidden_items[item.id] = item
527
for item in newly_matching:
528
del self._hidden_items[item.id]
530
def move_items(self, insert_before, item_ids):
531
"""Move a group of items inside the list.
533
The items for item_ids will be positioned before insert_before.
534
insert_before should be an iterator, or None to position the items at
538
new_iters = _ItemReorderer().reorder(self.model, insert_before,
540
self._iter_map.update(new_iters)
542
def _find_newly_matching_items(self):
544
for item in self._hidden_items.values():
545
if self._should_show_item(item):
549
def _remove_non_matching_items(self):
551
iter = self.model.first_iter()
552
while iter is not None:
553
item = self.model[iter][0]
554
if not self._should_show_item(item):
555
iter = self.model.remove(iter)
556
del self._iter_map[item.id]
559
iter = self.model.next_iter(iter)
562
class IndividualDownloadItemList(ItemList):
563
"""ItemList that only displays single downloads items.
565
Used in the downloads tab."""
566
def filter(self, item_info):
567
return (item_info.is_external
568
and not (item_info.download_info
569
and item_info.download_info.state in ('uploading', 'uploading-paused')))
571
class ChannelDownloadItemList(ItemList):
572
"""ItemList that only displays channel downloads items.
574
Used in the downloads tab."""
575
def filter(self, item_info):
576
return (not item_info.is_external
577
and not (item_info.download_info
578
and item_info.download_info.state in ('uploading', 'uploading-paused')))
580
class SeedingItemList(ItemList):
581
"""ItemList that only displays seeding items.
583
Used in the downloads tab."""
584
def filter(self, item_info):
585
return (item_info.download_info
586
and item_info.download_info.state in ('uploading', 'uploading-paused'))
588
class DownloadingItemList(ItemList):
589
"""ItemList that only displays downloading items."""
590
def filter(self, item_info):
591
return (item_info.download_info
592
and not item_info.download_info.finished
593
and not item_info.download_info.state == 'failed')
595
class ConversionsItemList(ItemList):
596
"""ItemList that displays items being converted."""
597
def filter(self, item_info):
598
return item_info.converting
600
class DownloadedItemList(ItemList):
601
"""ItemList that only displays downloaded items."""
602
def filter(self, item_info):
603
return (item_info.download_info and
604
item_info.download_info.finished)
606
class _ItemReorderer(object):
607
"""Handles re-ordering items inside an itemlist.
609
This object is just around for utility sake. It's only created to track
610
the state during the call to ItemList.move_items()
614
self.removed_rows = []
616
def calc_insert_id(self, model):
617
if self.insert_iter is not None:
618
self.insert_id = model[self.insert_iter][0].id
620
self.insert_id = None
622
def reorder(self, model, insert_iter, ids):
623
self.insert_iter = insert_iter
624
self.calc_insert_id(model)
625
self.remove_rows(model, ids)
626
return self.put_rows_back(model)
628
def remove_row(self, model, iter, row):
629
self.removed_rows.append(row)
630
if row[0].id == self.insert_id:
631
self.insert_iter = model.next_iter(self.insert_iter)
632
self.calc_insert_id(model)
633
return model.remove(iter)
635
def remove_rows(self, model, ids):
636
# iterating through the entire table seems inefficient, but we have to
637
# know the order of rows so we can insert them back in the right
639
iter = model.first_iter()
640
while iter is not None:
643
# need to make a copy of the row data, since we're removing it
645
iter = self.remove_row(model, iter, tuple(row))
647
iter = model.next_iter(iter)
649
def put_rows_back(self, model):
650
if self.insert_iter is None:
651
def put_back(moved_row):
652
return model.append(*moved_row)
654
def put_back(moved_row):
655
return model.insert_before(self.insert_iter, *moved_row)
657
for removed_row in self.removed_rows:
658
iter = put_back(removed_row)
659
retval[removed_row[0].id] = iter